Resumo Geral — Módulos 1 a 5 e 8 a 12

Senta aqui, pega um café. Vou puxar com você, numa conversa só, os dez módulos que mais caem na prova integradora: os Fundamentos (1), Representação e Aritmética (2), ISA — Instruction Set Architecture, ou conjunto de instruções da arquitetura — e Endereçamento (3), Caminho de Dados (4), Unidade de Controle (5), Hierarquia de Memória (8), Cache (9), Memória Virtual (10), Entrada e Saída (11) e Interrupções (12). Esse texto não substitui o módulo cheio — quando bater dúvida funda, volta no material correspondente. Os Módulos 6 e 7 (pipeline de cinco estágios e hazards) ficam fora deste recorte, e o 13 a 15 também. Todo assembly aqui está no dialeto pic-as (o montador do XC8) — mnemônicos em minúsculas, SFRs em MAIÚSCULAS, operando de acesso omitido para SFR (pic-as auto-seleciona o Access Bank) e radix decimal por padrão. Esqueça o MPASM antigo.

Antes de começar, deixa eu provocar você com a pergunta que costura o curso inteiro: o que muda quando você sai do quadro-negro, cheio de máquinas abstratas, e bota a mão num chip de oito bits zumbindo na bancada? A resposta curta é “tudo, menos os princípios”. A resposta longa é este resumo. O fio condutor é sempre o mesmo: separar o que o programador enxerga (o contrato) do que o silício faz por baixo (a implementação), e, no final de cada bloco, confrontar o que você previu com o pulso que o osciloscópio mostra.

Esse encontro entre teoria e medição não é decorativo. É o critério honesto que separa quem entendeu de quem decorou. Vou repetir esse mantra ao longo do resumo, porque ele organiza tudo: cada conceito ganha uma fórmula, cada fórmula gera uma previsão e cada previsão é confrontada com o pulso no osciloscópio. Quando a previsão bate, parabéns; quando não bate, ou seu modelo está errado, ou sua medição está, ou existe um detalhe que você ignorou — e os três casos são oportunidades de aprendizado.

Uma observação metodológica antes de mergulhar: nenhum dos dez módulos vive isolado. O Módulo 1 fixa vocabulário que reaparece em todos os outros; o Módulo 2 fundamenta o que a ULA do Módulo 4 manipula; o Módulo 3 define o repertório que o Módulo 4 executa e o Módulo 5 orquestra; os Módulos 8 a 10 ampliam o conceito de memória que o Módulo 4 toca de leve; os Módulos 11 e 12 explicam como a CPU dos Módulos 4 e 5 conversa com o mundo. Quando você sentir que está perdendo o fio, volta neste parágrafo e relocaliza o módulo no mapa.

flowchart LR
  A[Mod 01<br/>Arq vs Org] --> B[Mod 02<br/>Dados e Aritmética]
  B --> C[Mod 03<br/>ISA e Endereçamento]
  C --> D[Mod 04<br/>Caminho de Dados]
  D --> E[Mod 05<br/>Unidade de Controle]
  E --> H[Mod 08<br/>Hierarquia de Memória]
  H --> I[Mod 09<br/>Cache]
  I --> J[Mod 10<br/>Memória Virtual]
  J --> K[Mod 11<br/>E/S]
  K --> L[Mod 12<br/>Interrupções]

Módulo 1 — Arquitetura e Organização, sem mistério

Pega dois Intel quaisquer da mesma família: um Core i9 caríssimo e um Core i3 modesto. O mesmo .exe (executável) roda nos dois, mas um termina em segundos e o outro penar minutos. Como? Aqui mora a distinção que o módulo 1 inteiro quer cravar na sua cabeça.

Arquitetura é tudo aquilo que você, programador, consegue tocar pelo código: as instruções que existem, os registradores que você nomeia, os modos de endereçamento, o modelo de memória, o que acontece quando dá exceção. É um contrato público — o fabricante promete “isto existe e faz aquilo”, e seu programa se apoia nessa promessa. A IBM foi quem cunhou esse contrato como um conceito separado em 1964, ao lançar a linha System/360: pela primeira vez, modelos baratos e modelos caríssimos da mesma família rodavam exatamente o mesmo binário. O barato era lento, o caro era rápido, mas o software não sabia disso — ele só conhecia a arquitetura. Foi o início do que hoje chamamos compatibilidade binária, e essa decisão arquitetural sozinha valeu bilhões de dólares à IBM nos vinte anos seguintes, porque clientes podiam comprar máquina barata, evoluir para máquina cara e levar todo o investimento em software.

Organização é como o fabricante implementou esse contrato por dentro: tem pipeline? Tem cache (memória rápida intermediária)? Em que frequência o clock vai? Os barramentos têm que largura? Dois chips podem ter organizações abismalmente diferentes implementando a mesma arquitetura — é por isso que o i9 e o i3 rodam o mesmo binário. O i9 tem mais núcleos, cache maior, frequência mais alta e pipeline mais profundo; o i3 tem o oposto. Arquiteturalmente são irmãos; organizacionalmente, vivem em planetas diferentes. Mesma ideia se aplica ao ARM Cortex-M0 (centavos de dólar, microcontroladores) e ao ARM Cortex-A78 (smartphones premium): mesma família arquitetural ARMv8, organizações radicalmente diferentes, e portabilidade binária preservada com cuidado pelos fabricantes.

O atalho mental para não errar nunca é o que chamo de prova do programador: mexe num detalhe; se o resultado lógico de algum programa correto mudar, é arquitetura; se só o tempo, o consumo ou o custo mudarem, é organização. Aplica isso a “trocar SRAM (Static Random Access Memory, memória RAM estática) de 2 KB por 8 KB sem mexer no mapa visível” e a resposta vem sozinha: organização. Aplica a “adicionar uma instrução nova MULDIV”: arquitetura, porque o repertório visível mudou. Aplica a “trocar o ripple-carry da ULA por um carry-lookahead”: organização, porque a soma do addwf continua entregando o mesmo resultado, só que mais rápido. Aplica a “remover o bit Z do STATUS”: arquitetura, porque programas que testavam Z deixariam de funcionar. Aplica a “aumentar o pipeline de 2 para 5 estágios”: organização, desde que os tempos relativos sejam mantidos no contrato. A prova é tão poderosa porque é binária — você não fica em cima do muro, você decide.

Existe ainda um terceiro termo, a microarquitetura, que vive dentro da organização e descreve detalhes finos da implementação interna do processador — quantos estágios de pipeline, quantas unidades funcionais em paralelo, qual a política de previsão de desvios, qual o tamanho dos buffers de reordenação. Atenção ao marketing: quando a Intel ou a Apple lançam “uma nova arquitetura” (Skylake, M2), no rigor técnico estão falando de uma nova microarquitetura dentro de uma família arquitetural pré-existente (x86-64 ou ARMv8-A). O contrato externo é antigo; o que mudou foi a fábrica por dentro. Microarquitetura volta com força nos Módulos 4 e 5; aqui basta saber que ela existe e que está dentro da caixa “organização”.

flowchart TB
  subgraph Arq [Arquitetura — vista do programador]
    A1[Instruções]
    A2[Registradores]
    A3[Modos de endereçamento]
    A4[Modelo de memória]
  end
  subgraph Org [Organização — implementação]
    O1[Pipeline]
    O2[Caches]
    O3[Frequência]
    O4[Largura física]
  end
  Arq -. mesmo contrato .-> Org

Aproveita para fixar a linha do tempo das gerações, porque ela aparece em prova com frequência. Primeira geração (1940-1955): válvulas, ENIAC (programado por reconexão de cabos), surgimento do programa armazenado com Von Neumann no EDVAC de 1945. Segunda (1955-1965): transistores discretos, FORTRAN (1957), COBOL (1960). Terceira (1965-1975): circuitos integrados, IBM System/360 (1964) e microprogramação. Quarta (1971 em diante): microprocessadores em um único chip, Intel 4004 (1971), 8086 (1978), viabilizando o PC. Quinta (1980 em diante): VLSI (Very Large Scale Integration), computação ubíqua, sistemas embarcados em todo canto. O PIC18F4550 que você tem na bancada, projetado por volta de 2007, é típico da quinta geração — mas executa internamente o modelo conceitual de 1945. A história do nosso campo é cumulativa: nada é descartado, tudo é refinado.

Dois modelos brigam por como organizar memória e CPU (Central Processing Unit, unidade central de processamento). Von Neumann (1945) é o famoso: instrução é número, mora junto com os dados, é lida pelo mesmo barramento. A genialidade conceitual está em “programa virou dado”: antes do EDVAC, programar era reconectar fios por horas; depois, virou escrever uma sequência de números, e isso destrava compiladores, sistemas operacionais e ferramentas de desenvolvimento. O modelo tem cinco subsistemas — unidade de controle, ULA, registradores, memória principal e E/S — e executa o ciclo de busca-decodificação-execução: o PC aponta a próxima instrução, a memória devolve, o IR recebe e decodifica, a ULA executa, o PC avança ou salta. Bonito de explicar, mas paga um pedágio — a CPU não consegue buscar instrução e ler dado no mesmo instante, porque o barramento é compartilhado. Backus chamou isso de gargalo de Von Neumann no Turing de 1977. A formalização do gargalo é

\tau \geq \tau_p + 2 \cdot \tau_m

onde \tau é o tempo mínimo por instrução, \tau_p é o tempo gasto na lógica do processador (decodificação, ULA, escrita interna), e \tau_m é o tempo de um acesso à memória — multiplicado por dois porque a instrução típica precisa buscar a instrução e ler ou escrever um dado. Quando memória ficou muito mais lenta que processador (a memory wall, ou parede de memória, fenômeno documentado por Wulf e McKee em 1995), o termo 2\tau_m passou a dominar e a CPU virou um trabalhador caro esperando o caminhão chegar. A diferença de velocidade entre CPU e DRAM cresceu por décadas a uma taxa absurda — CPU dobrando a cada 18 meses (Lei de Moore traduzida para velocidade), DRAM melhorando apenas marginalmente.

A resposta clássica em CPU de propósito geral foi caches em múltiplos níveis (Módulos 8 e 9). A resposta em microcontrolador foi outra: trocar o modelo. Harvard resolve fisicamente: memória de instrução de um lado, memória de dado do outro, dois barramentos independentes. O nome vem do Mark I de Howard Aiken, de 1944, máquina eletromecânica de Harvard que armazenava instruções em fita perfurada e dados em contadores eletromecânicos separados. No mesmo ciclo, o processador busca uma instrução e lê ou escreve um dado, sem briga. O preço da Harvard pura é que tabelas e constantes grandes ficariam confinadas à memória de dados, ocupando RAM cara mesmo sendo imutáveis. Solução: Harvard modificada.

O PIC18F4550 (microcontrolador da família PIC18 da Microchip, sigla Peripheral Interface Controller) usa Harvard modificada: na hora de querer uma tabela const em ROM (Read-Only Memory, memória só de leitura — no PIC, a Flash de programa), ele usa tblrd* (table read, leitura de tabela) para ler a Flash como se fosse dado, com o ponteiro TBLPTR (24 bits, dividido em TBLPTRU:TBLPTRH:TBLPTRL) e o resultado em TABLAT. Existem quatro variantes — tblrd*, tblrd*+, tblrd*-, tblrd+* — para acesso com pós-incremento, pós-decremento e pré-incremento, equivalentes a *p, *p++, *p--, *++p em C. O melhor dos dois mundos: o programa fica em Flash, dados em SRAM, e ainda dá para puxar tabelas grandes da Flash sem ocupar RAM. Quando você declara static const uint8_t tabela[] = {...} em C, o XC8 aloca isso na Flash e emite a sequência tblrd* sozinho. Detalhe curioso: o x86 moderno, para o programador, é Von Neumann puro — uma memória só. Mas as caches L1 dele são separadas em instrução e dado, organização de inspiração harvardiana. Arquitetura Von Neumann, organização mista — a prova do programador resolve o paradoxo numa frase.

Sobre o PIC18F4550 em si, decora as características; cai em prova com frequência: núcleo de 8 bits, ULA (Unidade Lógica e Aritmética) com WREG (Working Register, o registrador de trabalho — único acumulador do PIC) e STATUS (registrador de flags), Flash de 32 KB para programa (16 K palavras de 16 bits, reprogramável in-circuit via ICSP — In-Circuit Serial Programming), SRAM de 2 KB em 16 bancos selecionados pelo BSR (Bank Select Register, registrador que seleciona qual banco da RAM você está acessando), um Access Bank de 256 bytes que mistura SFRs (Special Function Registers, registradores de função especial — controlam os periféricos) com RAM rápida, EEPROM de 256 bytes para dados não voláteis, e uma fauna de periféricos no mesmo chip: ADC (Analog-to-Digital Converter, conversor analógico-digital, 10 bits, 13 canais), USB (Universal Serial Bus, 2.0 full-speed a 12 Mbps), UART (Universal Asynchronous Receiver/Transmitter, comunicação serial assíncrona), SPI (Serial Peripheral Interface), I²C (Inter-Integrated Circuit), três timers (Timer0 de 8/16 bits, Timer1 de 16 bits, Timer2 de 8 bits), três comparadores analógicos, dois módulos CCP (Capture/Compare/PWM, para captura de eventos, comparação e geração de PWM) e 35 pinos de E/S divididos em portas A, B, C, D, E.

Aplica a prova do programador no chip inteiro: as 75 instruções, o WREG, os flags do STATUS, os três espaços de memória (Flash, SRAM, EEPROM), SFRs como TRISx (registrador de direção de cada pino — 1 entrada, 0 saída), LATx (latch de saída — onde você escreve para acionar o pino) e ADCONx (controle do ADC) são arquitetura. Já o pipeline de dois estágios, o Access Bank, a largura física do barramento, o cristal externo, a divisão por quatro do clock e a PLL (Phase-Locked Loop, multiplicador de frequência) do USB são organização. Se a Microchip lançar amanhã um PIC18F4550 com pipeline de quatro estágios e fabricado em processo de 28 nm em vez dos 250 nm originais, seu código continua executando — é o teste decisivo da separação.

A cadeia de clock do PIC18 merece um parágrafo só dela porque é a fonte do erro número um do semestre. O cristal externo gera F_{osc}. Um divisor interno por 4 produz F_{cy}, o clock de máquina:

T_{cy} = \frac{4}{F_{osc}}

onde F_{osc} é a frequência do oscilador externo (do cristal, no caso do KIT). Com cristal de 8 MHz, T_{cy} = 500 ns. Com cristal de 20 MHz e PLL configurada para gerar F_{osc} interno de 48 MHz, T_{cy} \approx 83{,}33 ns. Quem confunde F_{osc} com F_{cy} erra a previsão por um fator de quatro e perde horas debugando código que está correto — o problema é só a fórmula errada no cálculo.

A equação que vai te perseguir o semestre inteiro é

T = N_{\text{instr}} \cdot \text{CPI} \cdot T_{cy}

onde T é o tempo total de execução do trecho de código, N_{\text{instr}} é o número de instruções executadas, CPI (Cycles Per Instruction) é o número médio de ciclos por instrução, e T_{cy} é o tempo de um ciclo de máquina. Cada fator depende de uma camada diferente: N_{\text{instr}} depende do algoritmo e do compilador (otimização agressiva reduz); CPI depende da arquitetura e da microarquitetura (pipeline e caches reduzem o CPI efetivo); T_{cy} depende exclusivamente da organização (frequência do clock). Otimizar de verdade é atacar os três simultaneamente: melhorar o algoritmo, escolher o compilador certo com as flags certas, e dimensionar a organização para o problema.

Quando o programa tem mistura de instruções com CPIs diferentes, o CPI médio é

\overline{\text{CPI}} = \sum_{i} f_i \cdot c_i

onde f_i é a fração das instruções executadas que pertencem à classe i (com \sum_i f_i = 1) e c_i é o CPI dessa classe. Programa com 90% de instruções de CPI 1 e 10% de desvios tomados (CPI 2) tem \overline{\text{CPI}} = 0{,}9 \cdot 1 + 0{,}1 \cdot 2 = 1{,}1. Generalizando ainda mais, defina o throughput como IPC (Instructions Per Cycle):

\text{IPC} = \frac{1}{\overline{\text{CPI}}}

onde IPC é o número médio de instruções concluídas por ciclo. No PIC18, em regime de pipeline cheio sem desvios, IPC chega perto de 1. Em processadores superescalares modernos (assunto do Módulo 15), IPC pode ultrapassar 4, com múltiplas unidades funcionais executando em paralelo. A 8 MHz e CPI médio de 1,2, o PIC18 entrega throughput de aproximadamente 8 \times 10^6 / (4 \cdot 1{,}2) \approx 1{,}67 \times 10^6 instruções por segundo, ou 1,67 MIPS.

E não esquece da Lei de Amdahl, formulada por Gene Amdahl em 1967:

S = \frac{1}{(1 - f) + \dfrac{f}{k}}

onde S é o speedup global obtido, f é a fração do tempo que foi efetivamente acelerada, e k é o fator de aceleração aplicado a essa fração. No limite k \to \infty, o teto é

S_{\max} = \frac{1}{1 - f}

onde 1 - f é a fração não acelerada, que segue consumindo o mesmo tempo absoluto. Tradução de boteco: se 10% do seu programa é serial e você paraleliza o resto até o infinito, o speedup máximo é dez. Acelerar por dez uma fração de 50% dá S = 1/(0{,}5 + 0{,}05) = 1{,}82, não 5,5. Acelerar por mil uma fração de 1% dá S \approx 1{,}01 — praticamente nada. Otimização só vale a pena onde o programa de fato gasta tempo, e o ganho global tem teto na fração não otimizada. Antes de otimizar, faz a pergunta certa: qual fração do tempo total essa rotina consome? Se for 5%, ganhar 50% nela rende míseros 2,5% no programa inteiro. Profile primeiro, otimize depois.

O contraponto histórico de Amdahl é a Lei de Gustafson (1988): quando o tamanho do problema cresce com os recursos disponíveis, o speedup escalado vale

S_{\text{Gustafson}} = (1 - f) + k \cdot f

onde k é o número de processadores e f é a fração paralelizável. Sem teto rígido — quanto mais processadores, mais o problema cresce. Em embarcado, o problema é fixado pela aplicação (tempo real, deadline), então Amdahl pesa mais. Em data center, onde você sempre pode processar mais dados, Gustafson pesa mais. Saber qual lei aplicar para qual contexto é parte da maturidade do arquiteto.

Módulo 2 — Bits, números e a famosa pegadinha do 0.1 + 0.2

Abre o console do navegador agora e digita 0.1 + 0.2. Sai 0.30000000000000004. Não é bug, é o IEEE 754 (padrão do IEEE — Institute of Electrical and Electronics Engineers — para aritmética de ponto flutuante) fazendo o trabalho dele desde 1985. Antes de chegar lá, recapitula com calma.

Tudo começa em sistema posicional: na base b, um número é a soma dos seus dígitos pesados por potências de b, escrito como

N = \sum_{i=0}^{n-1} d_i \cdot b^i

onde N é o número representado, b é a base do sistema, n é o total de dígitos usados, e d_i é o dígito da posição i (com 0 \le d_i < b). O binário (b=2) não venceu por elegância matemática — a base e \approx 2{,}718 é teoricamente ótima do ponto de vista de eficiência de representação, e projetos ternários como o computador soviético SETUN (1958) realmente funcionaram —, mas por robustez tecnológica: transistores em corte ou em saturação distinguem dois níveis de tensão com folga, mesmo com ruído elétrico, variação de temperatura e envelhecimento. Três níveis exigiriam tolerâncias muito mais finas e foram derrotados pela física.

O hexa (b=16) sobreviveu porque 16 = 2^4, e cada dígito hexa corresponde exatamente a um quarteto de bits — a conversão binário-hexa é simples agrupamento. Datasheets, dumps de memória e listings do MPLAB X preferem 0xA35C a 0b1010001101011100 porque é quatro vezes mais curto e perfeitamente equivalente. O octal (b=8) sobrevive em duas trincheiras: permissões Unix (chmod 755) e literais C com prefixo 0 (atenção armadilha: 010 em C vale 8 decimal, não 10 — fonte recorrente de bugs sutis em código que faz if (mes == 010)).

Para converter de qualquer base b para decimal, soma as contribuições posicionais usando a fórmula acima. Por exemplo, 1011₂ = 1 \cdot 8 + 0 \cdot 4 + 1 \cdot 2 + 1 \cdot 1 = 11. Para ir de decimal para b, faz divisões sucessivas e lê os restos do último para o primeiro: 11 ÷ 2 = 5 resto 1, 5 ÷ 2 = 2 resto 1, 2 ÷ 2 = 1 resto 0, 1 ÷ 2 = 0 resto 1 — lendo de baixo para cima dá 1011. Para frações, multiplicações sucessivas, lendo a parte inteira a cada passo. É num desses procedimentos de fração que aparece um fato perigoso: 0{,}1_{10} em binário é a dízima 0{,}0\overline{0011}_2. Verifique: 0{,}1 \times 2 = 0{,}2 (lê 0), 0{,}2 \times 2 = 0{,}4 (lê 0), 0{,}4 \times 2 = 0{,}8 (lê 0), 0{,}8 \times 2 = 1{,}6 (lê 1, sobra 0,6), 0{,}6 \times 2 = 1{,}2 (lê 1, sobra 0,2), 0{,}2 \times 2 = 0{,}4 (lê 0) — voltamos a 0,2, e o ciclo 0011 repete infinitamente. O número que você escreve em C como 0.1 não tem representação binária exata. Guarda isso, é a raiz do mistério final do módulo.

Para inteiros sem sinal, o processador opera no anel \mathbb{Z}/2^n\mathbb{Z} (os inteiros módulo 2^n) — soma 0xFF + 0x01 numa ULA de 8 bits e o resultado vira 0x00 com o flag C (carry, vai-um) ligado. Faixa de [0, 2^n - 1], nada mais. A aritmética modular tem propriedades algébricas precisas: comutativa, associativa, distributiva sobre multiplicação. Subtração com transbordo “negativo” também é bem definida: 0x00 - 0x01 em 8 bits dá 0xFF com flag de empréstimo (borrow) ligado. Essa regularidade matemática é o que permite que código que faz aritmética unsigned funcione previsivelmente mesmo em condições extremas.

Como a ULA do PIC18 tem 8 bits, declarar uint8_t em C produz uma instrução por operação aritmética; uint16_t encadeia duas, propagando carry; uint32_t quatro; uint64_t oito. O custo cresce linearmente com a largura, e isso é uma das primeiras decisões de eficiência consciente em embarcado. Sempre prefira tipos de largura explícita (uint8_t, int16_t) a int ou long, que mudam de tamanho entre o PIC e o seu notebook — int é 16 bits no XC8 e 32 bits no GCC para PC. Código aparentemente portável que depende dessas suposições explode na transição. A regra de ouro de embarcado é: nunca usar int cru; sempre usar <stdint.h> com larguras explícitas.

Para inteiros com sinal, três representações disputaram historicamente. Sinal-magnitude (o bit mais à esquerda é o sinal, os demais são a magnitude) tem zero duplicado (+0 e -0, padrões 0000 0000 e 1000 0000) e exige somador-subtrator separado com lógica condicional — sobrevive só na mantissa do IEEE 754. Complemento de um (negar invertendo todos os bits) também tem zero duplicado (+0 = 0000 0000, -0 = 1111 1111) e exige correção end-around carry na soma, em que o carry-out do bit mais significativo é somado de volta ao bit menos significativo. Quem ganhou foi o complemento de dois, definido por

N = -b_{n-1} \cdot 2^{n-1} + \sum_{i=0}^{n-2} b_i \cdot 2^i

onde b_{n-1} é o bit mais significativo (o de sinal, com peso negativo) e b_i são os demais bits da palavra. Ganhou com folga porque oferece três vantagens cumulativas: zero único, comparações por padrão de bits triviais (0x80 em 8 bits é o menor número, 0x7F é o maior — comparação como inteiros signed simples) e, principalmente, soma e subtração reutilizam o mesmo somador físico. Vale A - B = A + (-B), e a negação é “inverte os bits e soma 1” (consequência da congruência -N \equiv 2^n - N \pmod{2^n}). Isso economizou milhões de transistores na história. No PIC18 a instrução subwf (subtract WREG from file) reutiliza exatamente o silício de addwf. A faixa em n bits é [-2^{n-1},\ +2^{n-1}-1], assimétrica (o menor negativo não tem oposto representável: -128 em 8 bits, mas +128 não cabe), com zero único.

Truque mental para descobrir o valor de um padrão em complemento de dois com bit alto 1: subtrai 2^n do valor lido como unsigned. Em 8 bits, 0b11111011 vale 251 como unsigned; em complemento de dois, 251 - 256 = -5. Em 16 bits, 0xFFFE vale 65534 unsigned ou 65534 - 65536 = -2 signed. Para estender int8_t para int16_t, o bit de sinal é propagado para os oito novos bits à esquerda (operação chamada sign extension); para uint8_t, basta preencher com zeros. Confundir os dois é outro bug clássico: (int16_t)(int8_t)0xFF é -1, mas (int16_t)(uint8_t)0xFF é +255. A linguagem C tem regras precisas para essas conversões, e o XC8 segue ANSI à risca.

Agora a confusão que cai em prova: carry não é overflow. Carry C avisa “deu transbordo se você ler como unsigned (sem sinal)”. Overflow OV avisa “deu transbordo se você ler como signed (com sinal, em complemento de dois)”. Os dois flags são independentes: pode dar um, o outro, os dois, nenhum. Quatro exemplos em 8 bits para fixar.

Soma em hex Leitura unsigned Leitura c2 C OV Diagnóstico
0xFF + 0x01 255 + 1 = 256 -1 + 1 = 0 1 0 carry sem overflow
0x7F + 0x01 127 + 1 = 128 +127 + 1 erra 0 1 overflow sem carry
0x80 + 0xFF 128 + 255 = 383 -128 + (-1) erra 1 1 os dois flags
0x10 + 0x20 16 + 32 = 48 +16 + 32 = +48 0 0 tudo nos conformes

A regra do OV na soma é

OV = (s_a \oplus s_r) \wedge (s_b \oplus s_r)

onde s_a é o bit de sinal do primeiro operando, s_b o do segundo, s_r o do resultado, \oplus é o ou-exclusivo (XOR) e \wedge é o E lógico (AND). Em palavras: deu overflow se os dois operandos tinham o mesmo sinal e o resultado tem sinal diferente. Faz sentido — somar dois positivos não pode dar negativo, somar dois negativos não pode dar positivo. Se deu, o resultado verdadeiro não cabia. Para subtração, a regra equivalente é mais fácil de pensar como soma do negado: A - B é soma de A com -B, então o overflow ocorre quando A e -B têm o mesmo sinal e o resultado tem sinal oposto.

E como o PIC18 soma uint16_t (inteiro sem sinal de 16 bits) se a ULA dele tem só 8 bits? Em dois pedaços. Primeiro addwf no byte baixo — ele atualiza C. Depois addwfc (add WREG to file with carry, soma o WREG ao arquivo já considerando o carry) no byte alto. Beleza dessa técnica: o mesmo flag estende a aritmética para qualquer largura sem mudar uma porta do silício. Chama-se aritmética de múltipla precisão, e a mesma ideia escala para 32, 64 ou 128 bits — só encadear mais addwfc. É a forma como bibliotecas de criptografia operam com inteiros de 1024 ou 2048 bits em hardware de 64 bits para implementar RSA, e como o algoritmo Schoolbook multiplica polinômios grandes — a estrutura é hierárquica e o flag de carry é o cimento.

#include <stdint.h>
uint8_t soma16_ov(int16_t a, int16_t b, int16_t *r) {
    uint16_t ua = (uint16_t)a, ub = (uint16_t)b;
    uint16_t res = ua + ub;
    *r = (int16_t)res;
    uint8_t sa = (ua >> 15) & 1;
    uint8_t sb = (ub >> 15) & 1;
    uint8_t sr = (res >> 15) & 1;
    return (sa == sb) && (sa != sr);
}
Soma16:
    movf    a_l, W       ; lê byte baixo de a
    addwf   b_l, W       ; soma byte baixo de b, atualiza C
    movwf   r_l          ; grava resultado baixo
    movf    a_h, W       ; lê byte alto de a
    addwfc  b_h, W       ; soma alto com carry herdado
    movwf   r_h          ; grava resultado alto
    return

Voltando ao 0.1 + 0.2. Inteiros, mesmo em 64 bits, são insuficientes para problemas científicos: a massa de um elétron é 9{,}11 \times 10^{-31} kg e a massa do Sol é 2 \times 10^{30} kg. Sessenta e uma ordens de grandeza de diferença não cabem em inteiro algum. A solução é a notação científica em base 2. O padrão IEEE 754 arruma os reais em três pedaços (sinal, expoente e mantissa) com a fórmula

N = (-1)^s \cdot (1 + F) \cdot 2^{E - \text{bias}}

onde N é o número representado, s é o bit de sinal (0 positivo, 1 negativo), F é a fração armazenada (a mantissa sem o bit implícito), E é o expoente armazenado, e \text{bias} é a constante subtraída de E para permitir expoentes negativos. No float (formato binário32) são 1 + 8 + 23 bits e \text{bias} = 127; no double (binário64) são 1 + 11 + 52 bits e \text{bias} = 1023. Existe ainda binário16 (meia precisão, 1 + 5 + 10 bits, \text{bias} = 15), comum em redes neurais, e binário128 (precisão quádrupla, 1 + 15 + 112 bits), em cálculos científicos de alta exigência. A precisão relativa de cada um é

\varepsilon_{\text{binário32}} = 2^{-23} \approx 1{,}19 \times 10^{-7} \varepsilon_{\text{binário64}} = 2^{-52} \approx 2{,}22 \times 10^{-16}

onde \varepsilon é o menor incremento relativo representável em torno de 1,0 (a “espessura do float” naquele ponto da reta real, também chamada unit in the last place ou ULP). A faixa exponencial vai de aproximadamente 10^{-38} a 10^{38} para float, e 10^{-308} a 10^{308} para double — basta para qualquer problema físico que você consiga formular.

O padrão tem três truques que merecem destaque. O expoente é com bias (não complemento de dois) porque assim comparar dois floats vira comparar dois inteiros — William Kahan, pai do padrão e ganhador do Turing de 1989, defendeu isso por economia de hardware (um comparador serve para tudo). Implementação: a representação 00000000 corresponde a expoente -127, 01111111 corresponde a 0, 11111110 corresponde a +127. Comparação numérica de floats positivos vira comparação numérica de seus padrões de bits lidos como unsigned. A mantissa tem bit implícito à esquerda da vírgula, ganhando um bit grátis de precisão (24 bits efetivos em float, 53 em double), mas com uma exceção: quando E = 0, o número é denormal (bit implícito 0), permitindo perda gradual de precisão perto de zero em vez de salto brusco para zero — outro pedido de Kahan, motivado por estabilidade numérica.

E existem valores especiais — zero com sinal (\pm 0, com +0 == -0 por especificação, mas 1/(+0) = +\infty e 1/(-0) = -\infty), infinitos (\pm\infty, codificados com E = 255 e F = 0 no float, propagam como esperado em operações), e NaN (Not a Number, “não é um número”, codificado com E = 255 e F \ne 0). NaN propaga em qualquer operação: \text{NaN} + 5 = \text{NaN}, 0 \cdot \text{NaN} = \text{NaN}, \sqrt{-1} = \text{NaN}. Inclusive NaN != NaN, e é por isso que if (x != x) é o jeito canônico de detectar NaN em C — qualquer outra comparação contra NaN também retorna falso. Esse é um dos poucos casos em que igualdade reflexiva (x == x) é violada, e ela é intencional, para sinalizar o erro silenciosamente.

flowchart LR
  S[Sinal 1 bit] --> P[Padrão IEEE 754<br/>binário32]
  E[Expoente 8 bits + bias 127] --> P
  F[Fração 23 bits<br/>+ bit implícito] --> P
  P --> R["N = (-1)^s · (1+F) · 2^(E-127)"]

O padrão IEEE 754 ainda define quatro modos de arredondamento: para o mais próximo com desempate par (default, o “banker’s rounding” que reduz viés acumulado), para zero (truncamento), para mais infinito (teto), para menos infinito (piso). O modo default é o mais usado e o mais estável; outros são úteis em análise de intervalos e em arredondamento monotônico para algoritmos financeiros específicos.

A distribuição dos floats representáveis na reta real não é uniforme: a distância entre dois float consecutivos em torno de 1,0 é \approx 1{,}19 \times 10^{-7}; em torno de 10^6, fica perto de 0,12; em torno de 10^{30}, da ordem de 10^{23}. Precisão relativa constante, precisão absoluta variável — exatamente o que ponto flutuante promete. Em torno de zero, denormais preenchem o gap com gradual underflow. Aí o 0.1 + 0.2: 0{,}1 em binário é dízima, então a representação já entra arredondada para o float mais próximo. Mesma coisa para 0{,}2. Soma os dois arredondados e o resultado bate diferente do float que melhor aproxima 0{,}3 — por um bit no fim da mantissa. Três arredondamentos sucessivos para o representável mais próximo, e o 0.30000000000000004 aparece. Em hexadecimal IEEE 754 binário64: 0{,}1 é 0x3FB999999999999A, 0{,}2 é 0x3FC999999999999A, 0{,}3 é 0x3FD3333333333333, e a soma dá 0x3FD3333333333334 — diferença no último dígito hexa, último bit da mantissa.

Conselho que vale ouro: nunca compare floats por igualdade; use tolerância no estilo fabs(a - b) < eps. Reorganize algoritmos numéricos para evitar cancelamento subtrativo, em que dois números próximos em magnitude perdem todos os algarismos significativos ao serem subtraídos (problema clássico: calcular \sqrt{x+1} - \sqrt{x} para x grande dá zero por cancelamento; reescrever como 1/(\sqrt{x+1} + \sqrt{x}) resolve). Para dinheiro, use inteiros em centavos — sistemas financeiros sérios não usam float, e bancos centrais têm regulamentos contra isso. No PIC18, sem FPU (Floating-Point Unit, unidade de ponto flutuante em hardware — que o PIC não tem), cada operação em float queima ~200 ciclos da biblioteca do XC8 (compilador C da Microchip para a família PIC) — sempre que puder, use escala fixa. Representar temperatura em centésimos de grau (int16_t centesimos_C) é melhor que float graus_C em quase todo controle embarcado: cabe em 16 bits, soma em uma instrução, multiplica em um ciclo com mulwf, e a precisão de 0,01 °C é mais que suficiente para qualquer sensor real.

A ULA do PIC18 atualiza cinco flags em STATUS: C (carry, vai-um do bit 7), DC (digit carry, vai-um do bit 3, útil em BCD — Binary-Coded Decimal, decimal codificado em binário, em que cada nibble de 4 bits codifica um dígito decimal de 0 a 9, com dois dígitos por byte; usado em relógios e displays de sete segmentos), Z (zero), N (negativo, espelha o bit 7 do resultado) e OV (overflow signed). A combinação desses flags com as instruções de desvio condicional (bc/bnc/bz/bnz/bn/bnn/bov/bnov) implementa toda a lógica de fluxo baseada em comparação. Há um idioma curioso muito usado: movf reg, F parece não fazer nada (lê reg e escreve no próprio reg), mas atualiza o flag Z — funciona como teste-de-zero em um ciclo. O XC8 emite isso o tempo todo.

Última armadilha: no PIC18, depois de subwf (subtract WREG from file, subtrai o WREG do arquivo), C = 1 significa sem empréstimo — oposto do x86, onde C = 1 após sub significa que houve empréstimo. Erra essa convenção e seu laço executa mais uma ou menos uma vez do que devia, com bug sutil que só aparece em condições de borda. A documentação da Microchip chama esse flag de “borrow inverso”, e a lógica é: C = 1 quando minuendo \geq subtraendo, então bc (branch if carry) após subwf pula quando A \geq B. Decora essa frase.

Módulo 3 — ISA e Modos de Endereçamento

ISA (Instruction Set Architecture, arquitetura do conjunto de instruções) é uma palavra que assusta no começo e fica clara depois. É o “menu” que o processador oferece ao programador: quais instruções existem, o que cada uma faz, quais registradores você pode usar, como o endereço de memória é montado, o que acontece numa exceção. É um contrato, e é por causa desse contrato que um .exe compilado em 2003 ainda roda no chip de 2026 da mesma família. O custo é que decisões antigas viram fóssil: o x86 ainda carrega instruções de aritmética BCD (AAA, AAS, DAA, DAS) herdadas do 8086 de 1978, ninguém usa, ninguém remove, porque algum software de banco em produção nos anos 1980 pode ainda depender disso. Formalmente, uma ISA pode ser descrita como tupla

\mathcal{I} = (\Sigma, \mathcal{F}, \mathcal{R}, \mathcal{M}, \mathcal{A}, \mathcal{X}, \sigma)

onde \Sigma é o conjunto finito de instruções, \mathcal{F} é o mapeamento de padrões binários em operações abstratas (a decodificação), \mathcal{R} é o conjunto de registradores visíveis ao programador, \mathcal{M} é o modelo de memória, \mathcal{A} é o conjunto de modos de endereçamento, \mathcal{X} é o mecanismo de exceções, e \sigma é a função de transição de estado da máquina. Você não precisa decorar a tupla; basta saber que ISA é mais que “lista de instruções” — engloba também registradores, memória, endereçamento e exceções, todos como parte do contrato observável.

A noção de ISA como contrato estável merece ainda mais ênfase. Imagine uma fábrica de carros que troca completamente o motor a cada três anos, mas mantém pedal, volante e câmbio idênticos. O motorista (o programador) não percebe a troca — só o desempenho muda. Empresa que viola o contrato perde clientes na hora; empresa que honra acumula ecossistema de software ao longo de décadas. Foi essa decisão da IBM em 1964 que criou o efeito de rede que sustenta o x86 até hoje, quase cinquenta anos depois do 8086.

Toda instrução é uma palavra binária com dois pedaços: opcode (código da operação) e operandos (sobre o quê). No PIC18, addwf f, d, a (add WREG to file) ocupa 16 bits: 6 de opcode (001001), 1 bit d dizendo se o resultado vai para WREG ou para o próprio f, 1 bit a dizendo se é Access Bank ou banco do BSR, e 8 bits do endereço f. Tudo de tamanho fixo, codificação regular — alma RISC (Reduced Instruction Set Computer, computador de conjunto de instruções reduzido). Pouquíssimas exceções de dupla palavra (call longo, goto absoluto, movffmove file to file) são tratadas pelo pipeline como nop no segundo ciclo. A regularidade do formato é o que torna a decodificação trivial — os 6 bits altos identificam unicamente a operação, os 10 restantes especificam os operandos com posições fixas. Compare com x86: instruções variam de 1 a 15 bytes, com prefixos opcionais (REX, LOCK, segmento), opcode de 1 a 3 bytes, byte ModR/M opcional, byte SIB opcional, deslocamento de 1 a 4 bytes, imediato de 1 a 4 bytes — o decodificador é uma máquina monstruosa que custa transistores e atrasa o pipeline.

Existe uma classificação clássica das ISAs pelo número de operandos explícitos por instrução. Máquinas três-operandos (ADD R3, R1, R2) seguem o padrão RISC moderno — ARM e RISC-V. O resultado vai para um registrador diferente das fontes, então as fontes podem ser reutilizadas em instruções vizinhas sem reler da memória. Máquinas dois-operandos fundem segundo fonte com destino, como o x86 (ADD EAX, EBX significa EAX := EAX + EBX) — economiza bits no formato ao custo de destruir uma das fontes. Máquinas de acumulador usam um único operando explícito, com um registrador implícito (o acumulador) sempre presente — comum nos anos 1960 e 1970, quando registradores eram escassos. Máquinas de pilha não têm operandos explícitos: operações trabalham com os dois topos da pilha. A JVM (Java Virtual Machine) é uma máquina de pilha em software, e os primeiros processadores Burroughs B5000 dos anos 1960 eram máquinas de pilha em hardware. O PIC18 é máquina de um operando e meio: WREG é o acumulador implícito, e o bit d decide se o resultado vai para o WREG ou para o próprio registrador f — é um operando lógico que economiza bits no formato.

flowchart LR
  I["addwf f,d,a<br/>16 bits"] --> OP[opcode<br/>6 bits]
  I --> D[d - destino<br/>1 bit]
  I --> A[a - banco<br/>1 bit]
  I --> F[f - endereço<br/>8 bits]

Outra decisão arquitetural fundamental é comprimento fixo versus variável. Comprimento fixo (todas as instruções têm o mesmo tamanho em bits) facilita a busca (o PC avança sempre o mesmo número de bytes), simplifica o decodificador (campos sempre nas mesmas posições) e viabiliza pipeline robusto. Comprimento variável compacta o código (instruções comuns curtas, raras longas) e foi a aposta CISC. ARM Thumb (1994) tentou um meio-termo, com instruções de 16 bits compactas que coexistem com as de 32 bits. RISC-V tem extensão “C” (compressed) com filosofia parecida. O PIC18 é estritamente comprimento fixo de 16 bits, com aquelas três exceções de 32 bits — e essas três são tratadas pelo hardware como uma instrução de 16 bits seguida por um nop lógico, mantendo a regularidade do pipeline.

O coração do módulo são os modos de endereçamento, que respondem a uma pergunta só: “de onde vem o operando?”. A fluência nesses modos é o que separa quem lê assembly de quem realmente o compreende. Vamos um a um.

Imediato — o número está embutido na instrução, tipo movlw 0x2A (move literal to WREG, move o valor literal para o WREG); toda atribuição x = 42 em C vira isso. Vantagem: zero acessos à memória, latência mínima. Limitação: o operando precisa ser conhecido em tempo de compilação. No PIC18, instruções com lw no nome (movlw, addlw, sublw, andlw, iorlw, xorlw, mullw) usam imediato de 8 bits.

Registrador — o operando mora em registrador (no PIC18, esse “registrador especial” é o WREG, que nem aparece no formato porque é implícito). Em RISC clássico tipo ARM, registradores são endereçados explicitamente por um campo na instrução (5 bits para 32 registradores). Vantagem: registradores são a memória mais rápida do chip, próximos da ULA. Limitação: número pequeno (32 em ARM/RISC-V, mais reduzido em x86 visível).

Direto — a instrução carrega o endereço completo do operando, talvez complementado pelo BSR; no PIC18, os 8 bits de f na instrução combinam com o BSR para formar 12 bits de endereço efetivo (4 bits do BSR + 8 da instrução), com o bit a controlando se usa o Access Bank (banco virtual que une SFRs e RAM rápida em uma única faixa). Quando a = 0, o Access Bank é selecionado e o BSR é ignorado; quando a = 1, o BSR é usado. Vantagem: simples, eficiente. Limitação: precisa caber no formato da instrução, limitando o tamanho do espaço endereçável diretamente.

Indireto — o endereço está num ponteiro, e aqui entra a tropa FSR0, FSR1 e FSR2 (File Select Registers, registradores de seleção de arquivo — os ponteiros do PIC18, cada um com 12 bits, suficientes para todo o espaço de RAM). Cada FSR vem com cinco registradores virtuais associados: INDFn (acesso indireto sem mudar o ponteiro, *p em C), POSTINCn (acesso e pós-incremento, *p++ em C), POSTDECn (acesso e pós-decremento, *p-- em C), PREINCn (pré-incremento e acesso, *++p em C) e PLUSWn (acesso ao endereço FSR + WREG, *(p + w) em C). Esses registradores virtuais não existem fisicamente — quando você acessa POSTINC0, o hardware lê/escreve no endereço apontado por FSR0 e depois incrementa FSR0 em um.

Sem indireto, ponteiro em C não existe, lista encadeada não existe, passagem por referência não existe. É o modo decisivo. Todas as estruturas de dados dinâmicas, todas as travessias de arrays, todas as passagens por referência são implementadas, no fim, por endereçamento indireto. Aprender a ler indireto fluente é aprender a ler ponteiros em C com a percepção do que o compilador faz por baixo.

Indexado — base mais deslocamento; no PIC18 ele aparece justamente como PLUSWn. Equivalente direto ao *(arr + i) de C, ou ao arr[i] que é a mesma coisa. Em ARM/x86, há modos indexados mais ricos, como base + índice escalado (*(base + i*4) em uma instrução), úteis para arrays de tipos maiores que byte.

Tabela em Flashtblrd* com TBLPTR (24 bits, mapeando até 16 MB de Flash) e auto-incremento opcional; é como o XC8 acessa const declarados na Flash, e como você lê a tabela de caracteres do display Sunstar 2004A. É um modo de endereçamento peculiar do PIC18 porque cruza o limite entre Harvard e Von Neumann: usa indireto, mas o alvo é a memória de programa, não a de dados.

Sempre que olhar uma linha, pergunta: qual modo? E qual a contrapartida em C? movlw 10 é imediato (x = 10). movff a, b é dois acessos diretos (b = a entre duas variáveis em endereços fixos). incf POSTINC0 é indireto com pós-incremento (*p++ += 1 em C). Faz isso para vinte linhas seguidas de um disassembly seu, e os modos viram automáticos.

Sobre CISC versus RISC (CISC = Complex Instruction Set Computer, computador de conjunto de instruções complexo): a briga histórica acabou em empate técnico, mas vale conhecer o roteiro. As máquinas CISC dos anos 1960 e 1970 (VAX-11 da DEC, IBM System/370, x86 a partir de 1978) nasceram sob restrições brutais: memória cara (bits valiam ouro), memória lenta em relação ao processador, compiladores otimizadores inexistentes ou primitivos. A resposta natural foi instruções de comprimento variável (para compactar código), muitos modos de endereçamento (para reduzir o número de instruções), operações complexas em uma única instrução (MOVS, REP, STRCPY-like) e aproximação semântica com Pascal e Algol (semantic gap closure — a ideia de que assembly devia se parecer com linguagem de alto nível para facilitar a vida do programador humano). O VAX-11 da DEC, lançado em 1977, é o exemplo canônico: 304 instruções, 16 modos de endereçamento, operações complexas como POLY (avaliação de polinômio) e INDEX (acesso a array multidimensional com checagem de limites em uma única instrução).

Por volta de 1980, três grupos quase simultaneamente — IBM (projeto 801 de John Cocke, 1975-1980), Berkeley (RISC-I e RISC-II sob Patterson, 1980-1983) e Stanford (MIPS sob Hennessy, 1981-1984) — mostraram que apenas uma fração pequena das instruções CISC era de fato usada pelos compiladores, e que instruções de duração variável tornavam o pipeline praticamente inviável. Surgiu o RISC, com instruções de comprimento fixo, arquitetura load-store (acesso a memória só via instruções dedicadas, separadas das aritméticas), poucos modos de endereçamento (geralmente só imediato, registrador, base+deslocamento) e regularidade de decodificação. SPARC (1987), MIPS comercial (1985), ARM (1985), PowerPC (1991) seguiram a fórmula.

A surpresa histórica veio nos anos 1990: a Intel, em vez de abandonar o x86 (o que custaria toda a base instalada), passou a traduzir internamente as instruções CISC em micro-operações RISC (\mu\text{ops}) executadas por um núcleo internamente regular (Pentium Pro, 1995), mantendo externamente o contrato x86. Foi simultaneamente capitulação ao mérito do RISC (por dentro, RISC venceu) e vitória do princípio contratual da ISA (por fora, o contrato CISC antigo continuou intacto). O Intel moderno traduz CISC para micro-operações RISC por dentro — perdeu por fora, ganhou por dentro. AMD fez o mesmo com K6 (1997) e Athlon (1999).

O PIC18 é inequivocamente RISC (75 instruções, formato fixo de 16 bits, princípio load-store relaxado), mas com concessões CISC úteis em embarcado: bit d fundindo destinos, decfsz (decrement file, skip if zero — decrementa e pula numa única instrução, perfeito para laços com contador), dcfsnz/incfsz/infsnz (variantes para outras combinações), indiretos com auto-incremento. ARM e RISC-V acumularam extensões com o tempo (NEON, SVE para vetorização; criptografia; vírgula flutuante meia precisão) que os aproximaram do CISC. Convergência total: hoje a distinção é mais marketing que substância.

Você não precisa decorar as 75 instruções do PIC18; precisa reconhecer as famílias e saber consultar o datasheet. Movimentaçãomovf (file → W ou file, dependendo de d), movwf (W → file), movlw (literal → W), movff (file → file em dupla palavra, evita passar pelo W), e lfsr (que carrega 12 bits no FSR de forma atômica em dupla palavra). Aritméticaaddwf, addwfc (com carry), addlw (W + literal), subwf, subfwb (sub with borrow), sublw (literal - W), incf/decf, mulwf (W × file em um ciclo, resultado em PRODH:PRODL), mullw (W × literal); o PIC18 multiplica em um ciclo mas não divide em hardware, então divisão por constante vira >> quando a constante é potência de 2, divisão por valor genérico vira chamada de biblioteca cara (centenas de ciclos). Lógicaandwf, iorwf (OR inclusivo), xorwf, comf (complemento, NOT), swapf (troca nibbles, ótimo para BCD/ASCII), rlcf (rotate left com carry), rrcf (rotate right com carry), rlncf/rrncf (rotações sem carry). Manipulação de bitbcf (bit clear), bsf (bit set), btg (bit toggle), btfsc (bit test, skip if clear), btfss (bit test, skip if set); fazem em um ciclo o que em outras ISAs exigiria três (ler, mascarar, escrever). Controle de fluxobra (branch relativo de ±1024 instruções), goto (absoluto, dupla palavra), bz/bnz (branch if zero/not zero), bc/bnc (carry/not carry), bov/bnov (overflow), bn/bnn (negative), call/rcall (com salvamento de retorno na pilha de hardware de 31 níveis), return/retlw (return com literal em W)/retfie (return from interrupt enable), mais decfsz/incfsz para laços. Propósito específicosleep (entra em modo de baixo consumo até interrupção), reset (reinicia o chip), clrwdt (zera o watchdog, evitando reset), nop (útil em timings finos), e os quatro tblrd*.

Última lição prática: escreve em C. O XC8 com otimização moderada produz código que você levaria horas para igualar em assembly. Só desce ao assembly quando o Stopwatch do MPLAB X IDE (Integrated Development Environment, ambiente integrado de desenvolvimento — o IDE oficial da Microchip) provar que precisa — laço crítico que domina o tempo, rotina com timing milimétrico (LCD, varredura de teclado, geração de PWM em software), ou acesso a recurso que o C não expõe (como certas configurações de hardware). Reconhecer padrões de tradução ajuda muito a depurar: a = b; entre bytes vira movf b, W seguido de movwf a; a = b + c; vira movf b, W, addwf c, W, movwf a; if (n == 0) vira o idioma movf n, F seguido de bnz; e laços for crescentes costumam ser convertidos pelo compilador em contagem decrescente para usar decfsz (mais barata que incf + cpfsgt). Quando você vê esses padrões saindo do compilador, sabe que ele está fazendo o trabalho dele direito.

Módulo 4 — O Caminho de Dados, ou “como o chip executa de verdade”

Imagina um addwf soma, F no PIC18 (com soma no Access Bank, daí o operando de acesso ser omitido — o montador pic-as cuida disso). Em quatro pulsinhos do oscilador ele precisa buscar a próxima instrução, decodificar essa, ler o operando da RAM (Random Access Memory, memória de acesso aleatório), somar com WREG, atualizar cinco flags e gravar o resultado. Cinco coisas em quatro pulsos? Como? A resposta é a coreografia do caminho de dados mais a sobreposição via prefetch.

Caminho de dados é a expressão técnica para o conjunto físico de subsistemas — ULA, registradores, memórias, multiplexadores, barramentos — que transformam operandos em resultados. Imagina uma cozinha industrial: o caminho de dados é o conjunto de bancadas, fogões e pias por onde o ingrediente trafega; a unidade de controle (Módulo 5) é o chef que diz “agora corta isso, frita aquilo, mistura ali”. Os dois são imprescindíveis: chef sem cozinha não cozinha, cozinha sem chef vira bagunça.

A ULA é puramente combinacional: dois operandos A e B de n bits e um vetor de sinais de controle entram, um resultado R e um vetor de flags saem. Sem memória interna, sem clock próprio — em poucos nanossegundos depois das entradas estabilizarem, as saídas estão prontas. Formalmente é uma função

\text{ULA}: \{0,1\}^n \times \{0,1\}^n \times \{0,1\}^k \to \{0,1\}^n \times \{0,1\}^f

onde n é a largura dos operandos (8 no PIC18), k é o número de bits de controle que selecionam a operação, e f é o número de flags produzidos (5 no PIC18). Por dentro, um somador completo encadeado bit a bit (ripple-carry) para soma, arranjos paralelos de portas para AND/OR/XOR (uma porta por bit, sem dependência entre bits, então latência mínima), um barrel shifter (deslocador em barril, rede de MUXes que desloca o vetor de bits por uma quantidade arbitrária em uma passagem combinacional) para deslocamentos, e um MUX (multiplexador) no fim escolhe qual saída vale segundo o opcode da operação. O somador completo de um bit tem três entradas (a, b, c_{in}) e duas saídas

s = a \oplus b \oplus c_{in} c_{out} = (a \cdot b) + (c_{in} \cdot (a \oplus b))

onde s é o bit-soma da posição, c_{in} é o carry vindo da posição menos significativa, e c_{out} é o carry produzido para a posição mais significativa. Cerca de doze transistores em CMOS (Complementary Metal-Oxide-Semiconductor, a tecnologia de fabricação que domina a indústria desde os anos 1980). Encadeia oito desses e o carry propaga em série, com latência de cerca de oito atrasos de porta — cabe folgadamente no ciclo de 500 ns do PIC18.

Em CPUs de 32 ou 64 bits, encadear 32 ou 64 somadores em série produz latência proibitiva (cada atraso de porta sendo cerca de 50 ps em tecnologia moderna, 32 deles dariam 1,6 ns — comparável ao próprio ciclo de clock). Usa-se carry-lookahead, que calcula os carries em paralelo a partir das funções generate (g_i = a_i \cdot b_i, “este bit gera carry”) e propagate (p_i = a_i \oplus b_i, “este bit propaga carry vindo de baixo”), produzindo latência O(\log n) em profundidade, ao custo de mais área. Há ainda variantes como carry-skip, carry-select e Kogge-Stone com trade-offs entre área e latência. O PIC18, com somador de 8 bits e clock máximo de 12 MHz internos sem PLL, fica feliz com o ripple-carry: o caminho cabe no ciclo com folga, mais transistores nessa unidade seriam desperdício.

Sobre a parte lógica e de deslocamentos da ULA, vale notar que cada operação tem caminho próprio dentro da unidade, todos operando em paralelo a partir dos mesmos operandos. Quando você emite andwf, o resultado correto está calculado simultaneamente em todas as outras saídas (soma, OR, XOR, NOT, deslocamentos) — só não é escolhido pelo MUX final. Pode parecer desperdício, mas é mais barato em silício calcular tudo em paralelo e escolher depois do que fazer escolha antes de calcular. Os transistores que calculam soma quando você está fazendo AND não dissipam praticamente nada de energia adicional em CMOS estático (consumo dinâmico predomina, e ele depende das transições, não dos cálculos descartados).

O caminho crítico costuma ser o somador (oito atrasos de porta encadeados), então é ele quem manda na frequência máxima. Uma observação que parece bobagem mas resolve dúvida de prova: por que andwf não mexe em C, DC e OV? Porque o AND nem passa pelo somador — os fios que alimentariam esses flags não recebem sinal válido. Não é convenção, é topologia. O MUX final de flags só roteia C, DC e OV quando a operação envolveu o somador. Os flags Z (zero) e N (bit alto do resultado) são calculados sobre o resultado final, independentemente da operação, então são atualizados por todas as instruções aritmético-lógicas.

O banco de registradores existe porque memória rápida ocupa área e custa caro. Por que processadores têm registradores em vez de operarem direto sobre a RAM? Memórias rápidas (SRAM de uns nanossegundos) são caras por bit; memórias grandes (DRAM de gigabytes) são lentas. Se cada operando viesse da RAM externa, o ciclo seria dominado por tempo de acesso, e o processador passaria a maior parte do tempo esperando. A solução é manter, dentro do chip e perto da ULA, um conjunto pequeno de células de altíssima velocidade — os registradores. Eles são fabricados com a mesma tecnologia das portas lógicas da ULA, então acesso a um registrador é tão rápido quanto uma porta.

Um banco com r registradores de n bits possui p_r portas de leitura e p_w portas de escrita. Em RISC clássico, ADD R3, R1, R2 exige duas leituras simultâneas (R1 e R2) e uma escrita (R3), daí a convenção de duas portas de leitura e uma de escrita. Cada porta adicional custa MUXes, fios, área, consumo e atraso. Bancos de processadores superescalares têm 4, 6 ou 8 portas de leitura para alimentar várias unidades funcionais simultaneamente — explodem em complexidade.

No PIC18 ele é assimétrico, herança da família PIC original. Tem um único acumulador chamado WREG (visível, com endereço próprio mas implícito) e um amplo File Register endereçável que abriga SFRs e RAM de uso geral em 16 bancos de 256 bytes. A maioria das instruções tem a cara OP f, d, a. Em RISC clássico (ARM, MIPS), o banco é simétrico — duas portas de leitura, uma de escrita, todos os registradores intercambiáveis. No PIC18, WREG é fisicamente separado e dedicado a uma das portas da ULA; o File Register é uma SRAM dual-port que serve simultaneamente de operando e destino. Uma addwf f, Ff, soma com WREG e devolve o resultado a f no mesmo ciclo de máquina — porque a SRAM permite leitura e escrita simultâneas no mesmo endereço. WREG sendo um único registrador é uma decisão histórica da família PIC dos anos 1970 (Microchip ainda nem existia, o PIC era projeto da General Instrument) que foi preservada até hoje em nome da compatibilidade. Compradores modernos da Microchip esperam que código antigo continue rodando — é o efeito ISA novamente.

O Access Bank é um atalho — primeiros 256 endereços rápidos sem precisar de movlb (move literal to BSR, move literal para o BSR) para trocar de banco. Ele combina 96 bytes da RAM (endereços 0x00 a 0x5F) com 160 bytes de SFRs (endereços 0xF60 a 0xFFF) num único banco virtual selecionado pelo bit a = 0. Os SFRs incluem TRISx, LATx, PORTx, STATUS, BSR, WREG, FSR0/1/2, INTCON, T0CON, RCON e dezenas de outros. Tê-los no Access Bank significa que escrever em LATD é uma única instrução bsf LATD, 0 sem precisar de movlb antes. O XC8 já coloca lá, por padrão, suas variáveis mais usadas (via análise de hot-set durante a compilação, ou via instruções explícitas como __near em algumas versões). Cada movlb extra num laço apertado é ciclo desperdiçado, e mover variáveis quentes para o Access Bank é a primeira otimização que rende em embarcado.

Barramentos são as estradas internas, transportam uma palavra por ciclo. Em qualquer instante, um barramento de n bits transporta uma única palavra. No PIC18F4550 a via interna de dados tem oito bits e a via de instruções tem dezesseis bits, refletindo o tamanho fixo da maioria das instruções — assimetria típica das arquiteturas Harvard, em contraste com a Von Neumann pura, onde dados e instruções compartilham o mesmo barramento. Duas fontes empurrando dado ao mesmo tempo daria curto destrutivo, então dentro do chip a solução é multiplexador — um MUX antes de cada porta da ULA, antes do banco, antes do PC (Program Counter, contador de programa, que aponta para a próxima instrução). MUX é barato; somador é caro.

Em projetos modernos dentro de um chip, os tradicionais buffers tri-state (que podem assumir estado de alta impedância, equivalente a “desligado”) cederam lugar aos multiplexadores. Tri-state ainda aparece em barramentos entre chips (memória externa, periféricos em placa), mas dentro do silício é raro. Um MUX escolhe entre m entradas qual será conectada à saída segundo um sinal de seleção, sem nunca permitir conflito. Aparece antes de cada porta da ULA (para escolher entre WREG, operando do file register, ou imediato), antes de cada porta de escrita do banco (para escolher entre saída da ULA, valor lido da memória, ou outros) e antes do contador de programa (para decidir entre PC incrementado, alvo de desvio incondicional e alvo de desvio condicional).

A latência de um MUX é pequena: mesmo um caminho com cinco ou seis MUXes em série tem atraso inferior ao de uma única passagem pelo somador. Por isso os projetistas usam-nos com generosidade. Essa onipresença justifica a complexidade da unidade de controle (Módulo 5): cada MUX precisa de seu próprio seletor, e a unidade de controle gera o vetor de seletores a cada ciclo. O caminho de dados é o mapa rodoviário; o vetor de controle é o roteiro de viagem que diz qual rota tomar a cada cruzamento.

flowchart LR
  PC --> MP[Memória Programa]
  MP --> IR[Decoder/IR]
  IR --> BR[Banco / WREG]
  BR --> ALU
  IM[Imediato] --> M1[MUX] --> ALU
  ALU --> M2[MUX] --> BR
  ALU --> MD[Memória Dados]
  ALU --> FL[Flags]
  FL --> NPC[Próximo PC] --> PC

O ciclo de instrução clássico tem cinco fases: IF (Instruction Fetch, busca da instrução), ID (Instruction Decode, decodificação), EX (Execute, execução), MEM (Memory access, acesso à memória de dados) e WB (Write Back, escrita do resultado). Esse modelo de cinco estágios é a referência canônica de Hennessy e Patterson e estrutura o pipeline RISC clássico (MIPS, SPARC). O PIC18 não implementa explicitamente as cinco fases — esse modelo é mais útil para RISC com pipeline pleno (tema do Módulo 06). O PIC18 adota uma estrutura simplificada de duas fases sobrepostas via prefetch (pré-busca): enquanto a instrução i executa, a i+1 já está sendo buscada. É a Harvard pagando dividendos: memória de programa e memória de dados são fisicamente separadas, os dois acessos usam caminhos independentes que não competem por recursos.

Em consequência, a maior parte das instruções consome exatamente um ciclo de máquina, equivalente a quatro pulsos do oscilador (T_{cm} = 500 ns a 8 MHz, ou \approx 83 ns a 48 MHz com PLL). A exceção dolorida é o desvio tomado: a instrução pré-buscada vira lixo, descarta, busca de novo, custa 2 ciclos. É a manifestação concreta do hazard de controle que será generalizado no Módulo 07. Quem cai nessa armadilha? goto, call, rcall, bra tomado, return, retfie, e os skips (btfss, btfsc, decfsz, incfsz, cpfseq, cpfsgt, cpfslt) que efetivamente pulam a próxima instrução. Reconhecer essas no seu código e estimar a fração delas é essencial para previsões de tempo precisas.

Cada ciclo de máquina ainda se subdivide em quatro estados Q internos. Q1 amostra o PC e ativa a leitura da memória de programa (o endereço sai, a Flash começa a entregar). Q2 amostra o IR (registrador de instrução) e dispara a leitura do operando no File Register (a instrução chegou, a decodificação rola, o endereço do operando vai para a SRAM). Q3 é o pico da execução — a ULA produz resultado e flags (operação combinacional). Q4 escreve o destino e captura os flags em STATUS (transferência para os registradores de destino). Os estados Q de instruções consecutivas se sobrepõem: enquanto a instrução n está em Q3 e Q4, a n+1 está em Q1 e Q2. Eis o segredo da pergunta de abertura: as cinco operações conceituais não cabem em quatro pulsos quando executadas em série numa instrução só, mas cabem quando busca, decodificação, leitura, computação e escrita se intercalam entre instruções vizinhas. É exatamente o princípio do pipeline em sua forma mais simples.

A terceira tarefa do Projeto Integrador pede que você evidencie experimentalmente as fases acionando pinos do PORTB antes e depois de classes específicas de instruções e medindo, com analisador lógico, as larguras de pulso. A diferença entre uma instrução ALU monociclo e um desvio tomado é nitidamente distinguível — é a prova experimental do hazard de controle. Atenção metodológica: as próprias instruções de marcação (bsf, bcf sobre LATB) consomem um ciclo cada e entram na largura medida. Em medições rigorosas é preciso descontar esse custo, como se desconta a tara do recipiente antes de pesar a substância. Anote essa armadilha — é fonte recorrente de erro de 10-20% em relatórios.

O caminho de dados monociclo (cada instrução numa única borda de clock) aparece no módulo só como construção pedagógica. Nesse modelo, T_{clk} precisa acomodar o caminho mais lento — tipicamente uma carga, que envolve leitura da memória de dados como passo terminal:

T_{clk} \geq \max_\iota \mathcal{L}(\iota)

onde \mathcal{L}(\iota) é a latência combinacional da instrução \iota, e o máximo é tomado sobre todas as instruções \iota da ISA. A desvantagem é evidente: instruções simples gastam o mesmo tempo das complexas. Imagina um restaurante em que toda mesa só é liberada depois do tempo que a mesa mais demorada gasta — todos esperam o cliente do prato mais complexo. Ineficiente. CPUs reais adotam multiciclo (cada instrução pode levar 1, 2, 3 ciclos curtos conforme sua complexidade) ou pipeline (várias instruções em diferentes fases simultaneamente, throughput de uma por ciclo). O monociclo aparece aqui por clareza didática: a separação entre caminho de dados e controle, o papel dos MUXes, a organização do PC e a distinção entre instruções tipo R (registrador-registrador), tipo I (registrador-imediato) e de desvio reaparecem, com refinamentos, em todos os modelos mais sofisticados.

Análise quantitativa concreta: considera um laço com 100 iterações, cada uma com 5 instruções aritméticas monociclo e um desvio condicional de volta ao topo. Total de ciclos: 100 \cdot 5 + 99 \cdot 2 + 1 = 699, ou cerca de 350 µs a 8 MHz. Num sistema de tempo real com restrição de meio milissegundo de período, esse laço consome 70% do orçamento — análise idêntica à exigida na segunda tarefa do Projeto Integrador do módulo. Aprenda a fazer essa conta de cabeça: cada bra no fim do laço custa 2 ciclos por iteração, exceto a saída.

Tabela de ciclos típicos por classe de instrução no PIC18F4550 que vale memorizar.

Classe Ciclos
Aritmética/lógica registrador-registrador 1
Acesso ao Access Bank 1
Acesso a banco com movlb prévio 1 + 1 prévio
Desvio condicional não tomado 1
Desvio condicional tomado 2
goto, bra 2
call, rcall, return, retlw 2
Skip não disparado (btfss, btfsc) 1
Skip disparado 2 ou 3
mulwf, mullw (multiplicação) 1
tblrd* (leitura de Flash) 2

Por fim, vale citar a Lei de Amdahl aplicada ao caminho de dados. Suponha que aritméticas monociclo respondam por 80% do tempo e desvios pelos 20% restantes. Dobrar a velocidade das aritméticas sem mexer nos desvios produz ganho global S = 1/(0{,}2 + 0{,}8/2) \approx 1{,}67, não 2,0. A fração não acelerada atua como teto inviolável. Dobrar a velocidade de uma classe que responde por 5% do tempo raramente compensa. Antes de gastar transistores otimizando uma classe específica, profile o programa.

Módulo 5 — A Unidade de Controle, o regente da orquestra

Olha o caminho de dados pronto: ULA, banco, barramentos, MUXes. Falta alguém para dizer, a cada ciclo, “agora a ULA soma; agora pega o W; agora atualiza só Z e N”. Esse alguém é a unidade de controle. Ela não move dado — ela manda quem move. Imagina uma orquestra: os músicos (ULA, banco, barramentos) são profissionais habilíssimos, mas sem um regente indicando entradas, pausas e dinâmicas, produzem ruído. A unidade de controle é o regente: não toca instrumento, mas decide quem toca, quando, e com que intensidade. Sem ela, o caminho de dados é silício mudo.

Formalmente é uma função

\mathcal{K} : IR \times Q \longrightarrow \{0,1\}^n \times Q

onde \mathcal{K} é a função de controle, IR é o registrador de instrução corrente (Instruction Register, contém o opcode em execução), Q é o conjunto de estados internos da unidade, n é o número de sinais de controle que ela gera, e \{0,1\}^n é o vetor desses sinais (cada bit aciona uma chave do caminho de dados). A função devolve dois resultados acoplados: o vetor de sinais a aplicar agora e o próximo estado a assumir. Em outras palavras, é uma máquina de estados finitos — FSM (Finite State Machine).

Para tornar isso palpável, acompanha um addwf f, d, a no PIC18: ao decodificar o opcode 001001daffffffff, a unidade de controle ativa simultaneamente os sinais que selecionam soma na ULA, conectam \text{Mem}[f] e WREG às entradas, encaminham o resultado ao destino determinado por d (WREG se d = 0, f se d = 1), e habilitam a captura de C, DC, Z, N e OV no STATUS. Em andwf f, d, a, opcode 000101daffffffff, muda só o código da operação na ULA, e a máscara dos flags exclui C, DC e OV — pelo motivo topológico do Módulo 4. Em bcf f, b, a, opcode 1001bbbaffffffff, a unidade ativa os sinais que leem f, mascaram o bit b com zero (operação dedicada do barrel shifter), e escrevem de volta em f — sem passar pelo somador, sem atualizar flag algum.

A FSM tem dois sabores, descobertos por Moore (1956) e Mealy (1955). Em Moore, a saída só depende do estado atual: \lambda(q). Em Mealy, a saída depende do estado e da entrada: \lambda(q, x). A escolha não é estética: em Moore, as saídas permanecem firmes durante todo o intervalo em que a máquina ocupa um estado, sem pulsos transitórios que flip-flops sensíveis à borda interpretariam como pulsos válidos. Em Mealy, a saída pode mudar imediatamente com a entrada, abrindo brecha para glitches (pulsos espúrios de duração curta causados por atrasos diferenciais nas portas lógicas) — em troca, exige menos estados.

Para processadores, a escolha quase universal é Moore — e o PIC18 não é exceção. É essa decisão que torna o timing do PIC absurdamente previsível: cada instrução tem latência conhecida ao nanossegundo. Em sistemas de tempo real, essa previsibilidade é o ouro: você sabe que o motor passo-a-passo vai receber o próximo pulso exatamente em T_{cy} \times 200 nanossegundos, e o controle de posição é estável. Em CPUs Mealy ou com microarquitetura especulativa (Intel Core, AMD Ryzen), o timing varia conforme o estado da cache, da pipeline, dos preditores de desvio — bom para média de desempenho, péssimo para garantia de pior caso.

Cada fase do ciclo de instrução vira um estado da FSM. Para um processador hipotético com três classes — tipo R (operações entre registradores), tipo M (acesso a memória), tipo B (desvios) — a máquina parte de FETCH, transita para DECODE e ramifica: R vai a EXEC_R e WB; M vai a EXEC_M e dali a MEM_R ou MEM_W; B vai a EXEC_B e, conforme a condição, atualiza ou não o PC. A tabela de transições, com colunas estado atual, condição (campos do IR e flags), próximo estado e sinais ativos, é o documento de projeto definitivo: no hardwired ela vira expressões booleanas em portas; no microprogramado, vira microinstruções em memória.

stateDiagram-v2
  [*] --> FETCH
  FETCH --> DECODE
  DECODE --> EXEC_R: tipo R
  DECODE --> EXEC_M: tipo M
  DECODE --> EXEC_B: desvio
  EXEC_R --> WB
  EXEC_M --> MEM
  MEM --> WB
  EXEC_B --> FETCH
  WB --> FETCH

Tem duas filosofias para implementar essa FSM. Hardwired (cabeada) — a máquina vira silício direto: flip-flops armazenam o estado em binário, uma rede de portas combinacionais alimentada por estado atual e bits do IR calcula simultaneamente o próximo estado e o vetor de sinais, e a cada borda ativa do clock os flip-flops capturam o novo estado. Rápido (latência de nanossegundos), pequeno, perfeito. Único problema: descobriu bug, refabrica o chip. O caso emblemático é o FDIV (Floating-point DIVision, instrução de divisão em ponto flutuante) do Pentium em 1994 — um bug numa tabela combinacional do algoritmo SRT (Sweeney, Robertson, Tocher — divisão por subtração condicional) entregava resultados imprecisos para certos pares de operandos. A Intel inicialmente tentou minimizar, mas a pressão pública e corporativa forçou a substituição de todos os chips afetados ao custo de US$ 475 milhões. Sem possibilidade de patch via software, porque a lógica era hardwired.

Microprogramado — Maurice Wilkes, em paper de 1951, propôs guardar as sequências de sinais como microinstruções numa memória interna, e cada instrução da ISA vira um pequeno programa nessa linguagem chamada microcódigo. Foi uma ideia revolucionária: aplicar o princípio “programa armazenado” não ao programa do usuário, mas à própria implementação do processador. Cinco componentes operam em harmonia: o \mu AR (microaddress register) contém o endereço da microinstrução atual; a memória de controle (ROM ou RAM rápida) guarda as micro-rotinas; o \mu IR (microinstruction register) mantém a palavra recém-lida e roteia seus campos aos fios do caminho de dados; o microsequenciador decide a próxima microinstrução (incremento sequencial, desvio incondicional para uma micro-rotina específica indicada pelo opcode, ou desvio condicional baseado em flags); e a ROM de mapeamento traduz opcode em endereço inicial da microrotina.

Formatos vão do horizontal (cada bit controla um sinal — decodificação trivial, paralelismo máximo, memória grande, palavras de 100-200 bits) ao vertical (sinais agrupados em campos compactos, decodificados por nível extra de lógica para produzir o vetor completo, palavras de 20-30 bits, latência extra). Híbridos com campos parcialmente codificados são o caso típico em sistemas reais. O VAX-11/780, lançado pela DEC em 1977, tinha uma memória de controle de cerca de 100 KB, suficiente para implementar todas as 304 instruções da ISA com suas múltiplas variantes — algo impensável em hardwired puro na época.

A vantagem decisiva do microprogramado é a flexibilidade pós-fabricação: atualizações de microcódigo corrigem bugs e até acrescentam instruções. Boa parte das correções de Spectre e Meltdown em 2018 chegou por update de microcódigo distribuído por BIOS/UEFI — eles modificaram a sequência de micro-operações usada por instruções vulneráveis (em alguns casos forçando barreiras de memória adicionais) sem trocar o chip. Outros exemplos: a Intel adicionou instruções AES (criptografia) e BMI (manipulação de bits) em gerações sucessivas via atualizações de microcódigo combinadas com hardware preparado. O custo é desempenho: cada instrução paga 1-2 ciclos extras lendo a memória de controle.

Processador moderno faz híbrido: caminho rápido hardwired para as instruções frequentes (\mu\text{ops} simples de inteiros, load/store, branches), caminho exótico via microcódigo para instruções complexas e raras (enter, loop, iret em modo legado, instruções de string como rep movs). É a regra geral da engenharia de sistemas: otimize o caminho frequente, trate exceções com flexibilidade. O PIC18 é hardwired puro — 75 instruções regulares cabem nele sem suar, e a previsibilidade absoluta de timing é justamente o que sistemas embarcados precisam. Microchip não emite atualizações de microcódigo para PIC18; se descobrirem bug, lançam revisão de silício e errata no datasheet.

Vale revisitar o pipeline de dois estágios do PIC18 (IF, EX) que já apareceu no módulo 4, agora com a equação formal. Com o PLL habilitado e cristal externo de 20 MHz no KIT, F_{osc} interno chega a 48 MHz (multiplicação por 2,4 efetiva no caminho USB), e

T_{Qx} = \frac{1}{48 \times 10^6} \approx 20{,}83 \text{ ns} T_{cm} = 4 \cdot T_{Qx} \approx 83{,}33 \text{ ns}

onde T_{Qx} é a duração de um estado Q interno e T_{cm} é a duração de um ciclo de máquina completo (quatro estados Q). Esse ciclo de 83 ns é o número que você usará em todos os cálculos de tempo do Projeto Integrador quando o PLL estiver ativo. Para programas sem PLL, valem os 500 ns típicos de cristal de 8 MHz.

O pipeline flui enquanto as instruções são sequenciais. O problema aparece com desvios tomados: quando um goto destino está em EX, o IF já buscou a instrução seguinte; ao executar o goto, o PC vira destino e a instrução pré-buscada é descartada — bolha. O CPI da instrução de desvio tomado sobe de 1 para 2. Sendo f_b a fração de instruções executadas que são desvios efetivamente tomados,

\overline{\text{CPI}} = (1 - f_b) \cdot 1 + f_b \cdot 2 = 1 + f_b

onde \overline{\text{CPI}} é o CPI médio efetivo do programa (incluindo as bolhas), e f_b é a fração das instruções executadas que são desvios efetivamente tomados (que descartam a instrução pré-buscada). Desvios não tomados não incorrem em penalidade, porque a instrução pré-buscada é justamente a próxima a executar. Laço com corpo de dez instruções tem f_b \approx 9\% e CPI \approx 1{,}09. Laço com corpo de duas instruções tem f_b = 50\% e CPI vira 1{,}5 — perdeu um terço do throughput (vazão). Reconhecer essas situações é parte da terceira tarefa do Projeto Integrador.

Daí surgem três técnicas para reduzir f_b. Programação sem desvio (branchless programming) — substitui um if-else por expressão aritmética; valor absoluto de um inteiro de 8 bits com sinal vira (x \oplus m) - m, onde m é a máscara obtida por deslocamento aritmético do bit de sinal (m = 0x00 se x \ge 0, m = 0xFF se x < 0). Custo fixo, nenhum salto, ideal para hardware com pipeline e sem preditor de desvios. Expansão de laços (loop unrolling) — para corpos curtos e iterações poucas, replica o corpo do laço N vezes e elimina os desvios intermediários ao preço de mais código (clássico trade-off espaço × tempo). Útil quando o programa tem espaço em Flash de sobra e o laço é crítico no tempo. Reorganização do teste — coloca o caso frequente no caminho não tomado. Inverte if (cond_rara) raro; else frequente; para if (!cond_rara) frequente; else raro;, e o caso comum deixa de pagar a bolha em quase toda iteração.

A operação síncrona impõe restrições temporais inelutáveis. Cada flip-flop D, o elemento básico de armazenamento síncrono, tem três parâmetros temporais críticos: t_{su} (setup time, o dado precisa estar estável esse tempo antes da borda do clock), t_h (hold time, o dado precisa permanecer estável esse tempo depois da borda) e t_{c \to q} (clock-to-Q delay, o tempo entre a borda do clock e a saída válida). Para operação correta entre dois flip-flops com lógica combinacional entre eles, vale a restrição

T_{clk} \geq t_{c \to q} + t_{\text{logic}} + t_{\text{routing}} + t_{su}

onde T_{clk} é o período do clock, t_{c \to q} é o atraso clock-to-Q do flip-flop de origem, t_{\text{logic}} é a soma dos atrasos das portas combinacionais no caminho, t_{\text{routing}} é o atraso dos fios que ligam as portas, e t_{su} é o setup do flip-flop de destino. O caminho combinacional com maior atraso — o caminho crítico — determina f_{\max}, a frequência máxima de operação. A Microchip dimensionou o PIC18F4550 para estabilizar com folga dentro do orçamento de 20{,}83 ns por estado Q nas piores condições de temperatura e tensão de alimentação. Para o engenheiro de firmware a consequência é direta: dentro da especificação o chip funciona; fora dela, surgem travamentos aleatórios sem mensagem de erro, e o debug fica brutal.

A separação ISA versus microarquitetura é a lição mais importante do módulo. ISA é o contrato; microarquitetura é a implementação. Você pode rasgar a microarquitetura inteira sem invalidar software, desde que continue honrando a ISA. Software do 8086 de 1978 ainda roda no Core i9 de hoje, e o Core internamente nada tem do 8086 — traduz x86 em \mu\text{ops} RISC, executa várias por ciclo fora de ordem com algoritmo de Tomasulo (1967, originalmente para o IBM System/360 Model 91), tem três níveis de cache, predição de desvios sofisticadíssima com TAGE (Tagged Geometric history), execução especulativa que tem que ser revertida em mispredictions. Para o PIC18 a separação é igualmente real: o .hex gerado pelo XC8 é uma sequência de palavras da ISA do PIC18; o pipeline de dois estágios, a ULA de 8 bits, o controle hardwired e o Access Bank são detalhes de microarquitetura. Internamente, addwf f, F é decomposto em microoperações que mapeiam diretamente em Q1 a Q4. O programador percebe uma única instrução; a unidade de controle orquestra quatro microoperações.

Para o firmware do Projeto Integrador, tudo o que vimos sobre máquinas de estados tem aplicação direta. Implementar uma FSM em C para gerenciar modos de operação — operação normal, configuração, diagnóstico — é, do ponto de vista lógico, o mesmo problema que o engenheiro de hardware enfrenta ao projetar uma unidade de controle. Mudam só as ferramentas (software no lugar de silício) e as restrições (RAM e ciclos no lugar de área e atraso combinacional). A forma canônica em C combina uma variável de estado, idealmente como enum, e um switch que determina ação (\lambda) e próximo estado (\delta). Alternativa mais escalável substitui o switch por tabela bidimensional indexada por estado e entrada, com lookup O(1). Quatro armadilhas recorrentes consomem horas de depuração: (1) defina sempre transição padrão para toda entrada possível (fallback seguro mantendo o estado); (2) transcreva o diagrama em tabela antes de escrever uma linha de código (inconsistência entre diagrama e código é a maior fonte de bugs); (3) leia todas as entradas no início do tick e trabalhe sobre snapshot (evita leitura inconsistente quando uma entrada muda no meio do processamento); (4) mantenha debouncing em ISR de Timer0 e entregue à FSM apenas eventos filtrados.

Módulo 8 — Hierarquia de Memória, ou por que ninguém usa só SRAM

Três coisas você quer numa memória: rápida, grande e barata. As três brigam. SRAM (seis transistores por bit, latch estático que mantém o dado enquanto a alimentação estiver ligada) é rápida (1-2 ns no PC, comparável ao registrador), pequena (caches L1 de dezenas de KB, L2 de centenas, L3 de dezenas de MB) e cara por bit. DRAM (Dynamic Random Access Memory, RAM dinâmica — um transistor + capacitor por bit, precisa refresh periódico porque o capacitor descarrega em milissegundos) é mediana (100 ns), grande (GB por chip moderno) e barata. Flash NAND ou NOR é barata por bit e densa (Terabytes por SSD), mas escrita é lenta (microssegundos a milissegundos) e tem vida útil finita (10⁴ a 10⁶ ciclos de escrita por célula). EEPROM é como Flash mas com escrita byte-a-byte em vez de bloco-a-bloco. A saída engenhosa é a hierarquia: empilhar essas memórias em camadas, com a mais rápida e cara em cima, e fazer o programa típico parecer que vive na camada mais rápida.

Vale entender por que cada tecnologia tem essa característica. SRAM 6T usa quatro transistores em dois inversores cruzados (formando o latch) mais dois transistores de acesso. Estável, rápida, mas ocupa muita área por bit. DRAM 1T1C usa um único transistor de acesso ligado a um capacitor que armazena a carga; densíssima, mas o capacitor descarrega por corrente de fuga em alguns milissegundos, exigindo refresh periódico (releitura e reescrita) que consome cerca de 1% da banda do chip. Flash usa transistores de porta flutuante: a porta flutuante guarda elétrons aprisionados por óxido isolante; ler é simples, mas escrever exige tunelamento Fowler-Nordheim ou injeção de hot electrons, processos lentos e que degradam o óxido a cada ciclo, daí o limite de durabilidade.

Isso só funciona porque programa real tem localidade, conceito formulado rigorosamente por Peter Denning em 1972 e que é a fundação teórica de toda a hierarquia de memória. Localidade temporal: o que você acessou agora você muito provavelmente vai acessar de novo logo (variáveis de laço, topo da pilha, contadores de iteração, registros recém-alocados, instruções dentro de um corpo de laço). Localidade espacial: o que está perto do que você acabou de acessar também vai ser acessado em breve (varredura de array, instruções sequenciais, percorrimento de struct campo a campo, leitura de buffers contíguos). Sem localidade, hierarquia não funcionaria — cada acesso seria miss, e o tempo médio cairia para o do nível mais lento. Felizmente, programas escritos com qualquer competência exibem localidade forte: a famosa regra dos 90/10 de Knuth e Denning diz que 90% dos acessos a memória, em programas típicos, miram em 10% do espaço de endereçamento durante uma janela de tempo razoável.

Saber explorar localidade é o que separa código rápido de código lento. Acessar uma matriz A[i][j] varrendo i no laço externo e j no interno tem boa localidade espacial em C (linhas contíguas em memória); inverter os laços destrói a localidade e pode tornar o programa 10x mais lento mesmo executando o mesmo número de operações aritméticas. Algoritmos como multiplicação de matrizes em blocos (tiling) reorganizam o acesso para maximizar reuso de cada bloco enquanto ele está na cache — ganhos de 5-10x sobre a versão ingênua são rotineiros.

flowchart TB
  R[Registradores<br/>~1 ns / dezenas de bytes] --> L1[Cache L1<br/>~1-2 ns / dezenas KB]
  L1 --> L2[Cache L2/L3<br/>~5-30 ns / MB]
  L2 --> M[DRAM<br/>~100 ns / GB]
  M --> D[Flash/Disco<br/>µs-ms / TB]

A fórmula do tempo médio de acesso num sistema de dois níveis é

\bar{t} = t_1 + (1 - h) \cdot t_2

onde \bar{t} é o tempo médio percebido pelo processador, t_1 é o tempo de acesso ao nível rápido (cache), t_2 é o tempo de acesso ao nível lento (memória principal) em caso de falha, e h é a taxa de acerto (hit rate) no nível rápido (com 0 \le h \le 1). Esta forma assume que o nível rápido é sempre acessado primeiro, e só na falha vai ao nível lento — modelo look-through. Há a variante look-aside, em que cache e memória são acessadas em paralelo e a cache cancela o acesso à memória se houver hit, com fórmula similar mas dinâmica diferente. Faz a conta com t_1 = 1 ns e t_2 = 100 ns: para h = 99\% o \bar{t} é 2 ns; para h = 95\% o \bar{t} é 6 ns; para h = 90\% vira 11 ns. Cinco vezes pior por causa de nove pontos percentuais de queda na taxa de acerto. É por isso que cache importa tanto, e por isso que otimizar para localidade é a primeira coisa que um programador de alto desempenho faz.

Generalizando para múltiplos níveis,

\bar{t} = t_1 + (1 - h_1)\Big[t_2 + (1 - h_2)\big[t_3 + \ldots\big]\Big]

onde h_i é a taxa de acerto no nível i e t_i é o tempo de acesso ao nível i em caso de falha do nível anterior. Cada novo nível só compensa se a taxa de acerto e a diferença de latência justificarem o custo. PCs modernos têm três a quatro níveis de cache (L1 de instruções + L1 de dados, L2 unificada por core, L3 compartilhada entre cores), mais memória principal DRAM, mais Flash SSD; cada nível é tipicamente 5-10x mais lento e 5-10x maior que o anterior.

No PIC18F4550 a hierarquia é declarativa e modesta, mas existe e merece ser mapeada. No topo, WREG e o Access Bank — acesso em um ciclo, sem movlb, sem penalidade. Logo abaixo, o resto da SRAM em outros bancos — também um ciclo de acesso, mas exige um movlb para trocar de banco, então um acesso isolado custa 2 ciclos e um acesso repetido no mesmo banco custa 1 cada. A Flash de programa, lida em um ciclo via prefetch enquanto a anterior executa, é praticamente grátis enquanto o fluxo for sequencial; desvio tomado paga a bolha do Módulo 5. Tabelas em Flash via tblrd* custam mais — duas a três instruções para configurar TBLPTRU:TBLPTRH:TBLPTRL mais a leitura. E na base, EEPROM (Electrically Erasable Programmable Read-Only Memory, memória só de leitura apagável e programável eletricamente) de 256 bytes para dados persistentes, com escrita lenta (~4 ms cada byte) via protocolo EECON1/EECON2 (registradores de controle da EEPROM, com sequência de habilitação de chave de 0x55, 0xAA para evitar escritas acidentais).

Para acessar EEPROM, o procedimento canônico é: carregar endereço em EEADR, dado em EEDATA, configurar EECON1 (EEPGD = 0, CFGS = 0, WREN = 1), desabilitar interrupções (bcf INTCON, GIE), escrever a sequência de chave em EECON2 (0x55 seguido de 0xAA), setar WR = 1 para iniciar a escrita, reabilitar interrupções, esperar WR voltar a zero (indicando fim da escrita, ~4 ms). É um protocolo paranoico, projetado para que a EEPROM nunca seja escrita por acidente — falhas de firmware que tentassem corromper configuração crítica precisariam reproduzir exatamente a sequência de chave para conseguir.

As tarefas do módulo são na ordem: mapear todos esses níveis com dados reais do datasheet (latência, tamanho, custo de troca); demonstrar localidade comparando varredura linear de um buffer contra acesso disperso (com osciloscópio medindo a diferença de tempo entre os dois padrões — buffer de 256 bytes varrido sequencialmente versus acessado em ordem aleatória); e reorganizar estruturas de dados para que o que mais usa caia no Access Bank, demonstrando ganho mensurável com Stopwatch. A última tarefa é a que mais ensina: descobrir, com instrumentação, que mover três variáveis do banco 1 para o Access Bank reduz o tempo de um laço em 15-20%. Esse hábito de medir antes e depois é a habilidade real que o módulo entrega. Ninguém ganha intuição de localidade lendo livro; só medindo.

Outro hábito que vale construir no módulo: ler o .map gerado pelo linker XC8. O arquivo lista cada variável global, cada array, cada região de memória usada, com endereços exatos. É lá que você descobre que o XC8 colocou seu buffer_uart no banco 2 em vez do Access Bank — informação invisível no fonte C, mas decisiva para desempenho. Diretivas como __near (XC8 v1) ou atributos __attribute__((address(0x100))) permitem forçar localização específica quando o linker não escolheu bem.

Módulo 9 — Cache, sem mistério

A cache é o nome que se dá àquela SRAM rápida intermediária que segura cópias do que está na memória maior e mais lenta. Aparece em três jeitos clássicos de organização. Mapeamento direto — cada bloco da memória principal tem um único lugar permitido na cache, calculado por

\text{índice} = \text{endereço do bloco} \bmod n

onde n é o número de linhas da cache e \bmod é a operação resto da divisão inteira. Simples, rápido (uma comparação só), mas se dois blocos disputarem o mesmo lugar, ficam empurrando um ao outro para fora a cada acesso (miss de conflito clássico). Vantagem: hardware mínimo, latência mínima. Limitação: se o seu programa por azar acessa repetidamente dois blocos que mapeiam para a mesma linha, a cache vira lixo (thrashing). Totalmente associativo — qualquer bloco pode ir a qualquer lugar; sem conflito, mas exige n comparadores paralelos, então o hardware fica caro e a latência cresce. Usado em TLBs e em caches pequenas. Associativo por conjunto de k vias — o meio do caminho que todo mundo usa na vida real. A cache se divide em conjuntos de k linhas cada; o índice escolhe o conjunto, e dentro do conjunto qualquer das k vias pode receber o bloco. k típico é 2, 4, 8 ou 16 — caches L1 de CPUs modernas tipicamente 8-way, L2 16-way, L3 12-16-way.

Em todos os casos o endereço vira três pedaços: tag (rótulo para identificar qual bloco mora ali), índice (para saber em qual conjunto procurar) e deslocamento (qual byte do bloco). Na hora de acessar, decompõe o endereço, vai ao conjunto indicado pelo índice, compara as tags armazenadas com a tag do endereço; se uma bate e a linha está válida, é hit (acerto), retorna o byte indicado pelo deslocamento; se nenhuma bate, é miss (falha), busca o bloco inteiro da memória mais lenta e instala numa das linhas do conjunto. Junto com o bloco vêm seus vizinhos contíguos (todo o bloco, tipicamente 32 ou 64 bytes), explorando localidade espacial — o byte que você acessou agora prevê acessos próximos em breve.

flowchart LR
  ED[Endereço] --> TG[Tag]
  ED --> IX[Índice]
  ED --> OF[Deslocamento]
  IX --> LN[Linha da cache]
  LN --> CMP{Tag bate?}
  TG --> CMP
  CMP -- sim --> HIT[Acerto]
  CMP -- não --> MISS[Falha → memória]

Exemplo concreto: cache de 4 KB com blocos de 16 bytes, 4 vias. Conjuntos: 4096 / (16 \cdot 4) = 64. Para endereço de 32 bits, o deslocamento usa 4 bits (\log_2 16), o índice usa 6 bits (\log_2 64) e a tag fica com os 22 bits restantes. Faz essas contas algumas vezes e elas viram automáticas — caem em prova com frequência. Outro exemplo: cache L1 de instruções típica do x86, 32 KB com blocos de 64 bytes, 8 vias. Conjuntos: 32768 / (64 \cdot 8) = 64. Deslocamento 6 bits, índice 6 bits, tag 32 - 12 = 20 bits (em modo 32 bits) ou 48 - 12 = 36 bits em modo 64 bits.

Política de substituição (quem sai quando o conjunto está cheio e precisa abrir espaço): LRU (Least Recently Used, menos recentemente usado — o melhor na prática mas caro de implementar em hardware para k grande), FIFO (First In, First Out, primeiro a entrar é o primeiro a sair — mais simples, contador circular), e aleatória (surpreendentemente competitiva, e trivial de implementar — gerador de números pseudo-aleatórios pequeno). Para k = 2, LRU vira um bit por conjunto (qual das duas foi usada por último), baratíssimo; para k = 8 ou maior, LRU exata vira impraticável e usa-se pseudo-LRU baseado em árvore binária (k - 1 bits por conjunto, indicando subárvore esquerda ou direita em cada nível). Há ainda políticas mais sofisticadas como RRIP (Re-Reference Interval Prediction), que estima quão cedo um bloco será reutilizado.

Política de escrita: write-through manda escrita para a cache e para a memória ao mesmo tempo (simples — a memória sempre tem a versão atualizada, garantindo consistência fácil, mas gera tráfego grande, mitigado por um write buffer que aceita as escritas em FIFO e as drena para a memória em segundo plano); write-back só escreve na cache e marca a linha como dirty (suja); a memória principal só recebe a atualização quando a linha for substituída (mais rápido na média, mais complexo, e exige bit de sujeira por linha, mais protocolo de coerência em sistemas multi-core). Sistemas modernos quase sempre usam write-back nas caches L1 de dados, write-through em algumas implementações de L1 de instruções (que raramente são escritas, então a economia não compensa o custo).

Política de alocação em escrita é uma decisão correlata. Write-allocate (mais comum): em miss de escrita, carrega o bloco na cache e depois escreve. No-write-allocate: em miss de escrita, escreve direto na memória sem trazer o bloco para a cache. Write-allocate combina naturalmente com write-back; no-write-allocate combina com write-through.

Tem ainda o modelo de Hill que decompõe miss em três tipos (os 3 Cs, formulado por Mark Hill em 1989): compulsório (primeira vez que toca naquele bloco — inevitável, mas pode ser amenizado com prefetching especulativo, em que o hardware adivinha o próximo bloco e o carrega antes do programa pedir), capacidade (seu conjunto de trabalho não cabe na cache, mesmo se fosse totalmente associativa — solução: cache maior ou algoritmo que reduza o working set, como percorrer matrizes em blocos), e conflito (vítima do mapeamento direto ou associativo por conjunto com k pequeno — solução: aumentar associatividade ou rearranjar os endereços usados para evitar a colisão). Saber qual tipo está dominando dita o tipo de otimização que faz sentido. Aumentar a cache não resolve miss conflito; aumentar associatividade não resolve miss capacidade. Profile primeiro (Intel VTune, AMD uProf, perf no Linux, Instruments no macOS), otimize depois.

Existe um quarto C às vezes citado: coerência, miss causado por invalidação por outro core em sistemas multi-core — sai do escopo deste módulo mas é central em sistemas paralelos.

Prefetching especulativo é interessante o suficiente para um parágrafo. O hardware observa o padrão de acessos (incrementos constantes, varredura linear, listas encadeadas com padrão regular) e busca antecipadamente os próximos blocos. Funciona maravilhosamente em código numérico bem-comportado e é parte do segredo do desempenho extremo de CPUs modernas em loops simples. Pode ser explicitado em software via __builtin_prefetch em GCC, instrução PREFETCH em x86.

O PIC18F4550 não tem cache de hardware. Mas a tarefa do módulo é caprichada: implementar uma cache em software — uma tabela pequena em RAM que segura cópias dos itens mais quentes de uma tabela maior em Flash ou EEPROM. Por exemplo, um sistema de monitoramento lê constantemente parâmetros de configuração que ficam em EEPROM (lenta); copiar os mais usados para uma tabela em SRAM com política LRU e atualizá-los só quando algum botão muda algo dispara a típica decisão write-through versus write-back. Você compara as políticas, mede taxa de acerto sob padrões diferentes de acesso (uniforme, gaussiana, com pico, com correlação temporal), troca LRU por FIFO por aleatória, registra tudo num gráfico de barras com erro padrão.

O aprendizado que sobra é o esquema mental — a cache vira uma estrutura de dados na sua cabeça, transferível para qualquer arquitetura, qualquer linguagem, qualquer escala. Quem entende cache entende também memoization (em programação funcional, cache de resultados de funções puras), buffer pool de banco de dados (páginas de tabela cacheadas em RAM para evitar leitura de disco), CDN (Content Delivery Network, cache geograficamente distribuída de conteúdo web), e materialized views de SQL — todos a mesma ideia com nomes diferentes em domínios diferentes.

Módulo 10 — Memória Virtual sem PC

Memória virtual nasceu de três frustrações dos anos 1960. Cada programa quer acreditar que tem o computador inteiro só para ele (isolamento de processos — o programa A não pode bisbilhotar nem corromper a memória do programa B, requisito fundamental para multitarefa segura e para o modelo de processos do Unix). Programa pode ser maior que a RAM física (paginação contra disco — rodar Photoshop com mais memória do que o PC tem, ou compilar projeto enorme num PC com 4 GB). E o programador não quer saber em qual endereço físico cada coisa caiu (transparência — o compilador gera endereços virtuais começando em zero como se o programa rodasse sozinho, e o sistema operacional resolve onde colocar de fato).

O sistema Atlas, da Universidade de Manchester (1962), foi o primeiro a implementar paginação completa em hardware, com tabela de páginas e tradução por meio de associative memory (precursora da TLB). Foi também o primeiro a oferecer o que hoje chamamos “memória virtual” — Tom Kilburn cunhou o termo “one-level store” para descrever a abstração de que RAM e drum (o disco da época) apareciam ao programa como um único espaço uniforme.

A MMU (Memory Management Unit, unidade de gerenciamento de memória) resolve os três: o processador trabalha com endereço virtual (o que o programa enxerga), a MMU traduz para físico (o que vai para o chip de RAM) consultando a tabela de páginas (estrutura em RAM que mapeia páginas virtuais para quadros físicos), e a TLB (Translation Lookaside Buffer, um pequeno cache totalmente associativo de traduções recentes, tipicamente 32 a 512 entradas) acelera o caso comum, evitando consultar a tabela em RAM a cada acesso. Memória é dividida em páginas de tamanho fixo (4 KB é o padrão em x86, 16 KB em ARM moderno, com suporte a páginas grandes de 2 MB ou 1 GB para reduzir a pressão na TLB), e a tabela de páginas tem uma entrada por página virtual do processo.

A tabela de páginas plana seria gigantesca para espaços virtuais grandes (em x86-64 com 48 bits, 2^{48}/4096 = 2^{36} entradas), então usa-se tabela hierárquica multi-nível: o endereço virtual se decompõe em vários índices, cada um indexa um nível, e só os caminhos efetivamente usados ocupam espaço. x86-64 usa 4 níveis (PML4, PDPT, PD, PT); ARM AArch64 também usa esquemas multi-nível configuráveis. RISC-V tem o esquema Sv39/Sv48/Sv57 conforme o tamanho do espaço virtual.

A TLB merece destaque porque é onde está boa parte do segredo do desempenho. Sem ela, cada acesso a memória precisaria primeiro consultar a tabela em RAM (4 acessos em x86-64 para traduzir um endereço!), e a memória virtual seria proibitivamente cara. Com TLB de boa taxa de acerto (>99% em programas bem comportados), a tradução é tipicamente um ciclo, transparente. Programas com working set que cobre poucos GB caem facilmente nesse regime; programas com varredura aleatória sobre dados grandes (muitos bancos de dados) sofrem TLB miss e pagam caro.

Quando a página não está em RAM (porque o sistema operacional moveu para o disco para liberar espaço), dispara page fault (falha de página). O hardware sinaliza uma exceção, o sistema operacional assume, escolhe uma página vítima na RAM, eventualmente escreve essa vítima no disco (se estava suja), traz a página solicitada do disco, atualiza a tabela de páginas e retoma a instrução interrompida. Uma única page fault custa milhões de ciclos — o disco é eras mais lento que a CPU; mesmo SSD NVMe atual fica em torno de 100 µs, e SSD SATA ou HDD ficam em milissegundos. O segredo de bom desempenho é evitar page fault, o que significa manter o working set (conjunto de páginas frequentemente acessadas, formalizado por Denning em 1968) cabendo na RAM. Quando o working set excede a RAM, o sistema entra em thrashing: page faults constantes, sistema operacional gastando mais tempo movendo páginas do que executando código útil. Sistemas em thrashing parecem travados — usuário vê CPU em 100% mas nada acontece.

flowchart LR
  CPU --> EV[Endereço virtual]
  EV --> TLB{TLB hit?}
  TLB -- sim --> EF[Endereço físico] --> MEM
  TLB -- não --> TP[Tabela de páginas em RAM]
  TP --> EF
  TP --> PF[Page fault → SO]

Os algoritmos de substituição de página são velhos conhecidos. FIFO — simples, mas pode piorar com mais quadros (a anomalia de Belady — contraintuitivo, mas demonstrável com sequência de referências específica como 1 2 3 4 1 2 5 1 2 3 4 5; com 3 quadros dá 9 faults, com 4 quadros dá 10 faults!). LRU — bom, mas caro: exige manter ordem temporal de acesso de todas as páginas em RAM. Implementações exatas precisam atualizar contador a cada acesso, custo proibitivo; implementações aproximadas usam bit de referência ou pilha. Clock (ou second chance) — jeito esperto de aproximar LRU com um bit de referência por página; o algoritmo varre as páginas como ponteiro de relógio, zera bits encontrados ligados (dando “segunda chance”) e escolhe a primeira com bit já zero. Imune à anomalia de Belady. Working set — mantém em RAM as páginas acessadas nos últimos \tau ciclos; ajusta dinamicamente o número de quadros conforme o working set do processo cresce ou diminui. Ótimo de Belady — escolhe sempre a página que será usada mais tarde; exige profecia, então não dá para implementar, mas serve como benchmark superior para comparar os outros algoritmos. Distância do ótimo é a métrica natural de qualidade.

A paginação contra segmentação é uma decisão arquitetural histórica. Segmentação divide a memória em segmentos de tamanho variável correspondentes a unidades lógicas (código, dados, pilha, heap), com proteção por segmento. Paginação divide em páginas de tamanho fixo, mais fácil de gerenciar mas perde a semântica de “unidade lógica”. Sistemas modernos usam paginação, com proteção a nível de página, e descartaram segmentação como mecanismo principal. x86 ainda carrega registradores de segmento (CS, DS, SS, ES, FS, GS) por compatibilidade, mas em modo 64 bits a maioria é fixada em base zero.

Agora, e no PIC18, que não tem MMU? A tarefa do módulo adapta as ideias para o que dá. Primeiro, implementar um alocador dinâmico mínimo (estilo malloc/free simples sobre uma região da SRAM, com lista encadeada de blocos livres — preocupação com fragmentação interna, que vem de blocos alocados maiores do que o pedido, e externa, que vem de espaços livres pequenos demais para qualquer alocação útil). Algoritmos clássicos: first-fit (primeiro bloco livre que serve), best-fit (menor bloco livre que serve, minimiza desperdício interno mas pode fragmentar externamente), worst-fit (maior bloco livre, deixa os fragmentos maiores e mais úteis). Estratégias mais sofisticadas usam buddy system (potências de 2) ou slab allocator (caches de objetos do mesmo tamanho).

Segundo, implementar swapping (troca) manual entre SRAM e EEPROM — cópia explícita de dados “frios” para a EEPROM, leitura sob demanda, com cuidado dobrado, porque a EEPROM tem vida finita: tipicamente

N_{\text{escritas}} \approx 10^{5}

onde N_{\text{escritas}} é o número máximo de ciclos de escrita por célula antes de a memória começar a falhar, segundo o datasheet da Microchip. Algumas EEPROMs modernas chegam a 10^6, mas a do PIC18F4550 fica em 10^5. Daí wear leveling simples — distribuir as escritas em um anel circular sobre várias células, para não desgastar uma só — ser bem-vindo. Se você gravar um log a cada segundo na mesma célula da EEPROM, a célula se queima em pouco mais de um dia (86400 \times 1{,}16 \approx 10^5 escritas em 1,16 dia); distribuído em 100 células, você tem 100 dias antes do primeiro falhar; em 10000 células (impossível, são 256 bytes só), você teria décadas. Algoritmos de wear leveling profissionais (em SSDs, eMMCs) movem dinamicamente blocos quentes para áreas pouco usadas, mantêm contadores de ciclo por bloco e dispõem da memória inteira como pool gerenciado.

Terceiro, e mais entregável: documentar o mapa de memória completo do projeto. Flash com vetores de reset (0x0000), de interrupção alta (0x0008) e de interrupção baixa (0x0018), seguidos do código de aplicação, das tabelas const em Flash; SRAM com pilha (cresce do topo para baixo a partir de 0x7FF no PIC18F4550), heap (cresce de baixo para cima — se houver alocador dinâmico), variáveis globais e estáticas alocadas pelo XC8 (verifique o .map do linker para endereços exatos), buffers de comunicação, áreas de SFR (0xF60 a 0xFFF); EEPROM com regiões para configuração persistente e logs com wear leveling.

Esse mapa de memória, na real, é o entregável que separa quem entendeu mesmo de quem só decorou nomes. Quando você sabe desenhar o mapa do seu projeto e justificar cada decisão (por que esta variável está no Access Bank, por que esta tabela está em Flash via tblrd*, por que a pilha de hardware nunca passa de 31 níveis), você passou. Quando você sabe ainda como o mapa se transforma ao longo do desenvolvimento (variáveis sendo movidas, buffers redimensionados, novas funções adicionadas com mais stack frames), você dominou. Esse hábito de pensar em mapa de memória te acompanha para arquiteturas muito mais complexas — kernel Linux, Java Virtual Machine, sandbox de browser — todos têm o conceito, em escalas diferentes.

Módulo 11 — Entrada e Saída: como falar com o mundo

Tem três técnicas básicas para o processador conversar com periférico. Polling (E/S programada com busy waiting, espera ocupada) — a CPU fica perguntando “já está pronto?” em loop, lendo o flag de status do periférico. Simples e com latência mínima — assim que o flag muda, a CPU responde —, mas queima ciclos da CPU à toa enquanto espera. Adequado quando o periférico é rápido em relação à CPU, ou quando você não tem mais nada para fazer, ou em sistemas determinísticos onde latência tem que ser fixa.

Interrupção — o periférico dá um toque na CPU quando está pronto, via fio dedicado (linha IRQ); enquanto não está, a CPU faz outra coisa (executa o programa principal, ou dorme em sleep para economizar energia). Mais eficiente em CPU, mas mais complicado de programar: estado compartilhado entre ISR (rotina de tratamento) e laço principal, prioridades, latência variável, possibilidade de aninhamento. Adequado quando o periférico é lento em relação à CPU, ou quando você precisa fazer várias coisas em paralelo, ou quando consumo de energia importa (CPU dormindo entre eventos).

DMA (Direct Memory Access, acesso direto à memória) — um controlador dedicado move blocos entre periférico e memória sem incomodar a CPU; a CPU programa o controlador (origem, destino, tamanho, modo) e dispara, depois é avisada por interrupção quando termina. Ideal para grandes volumes (Ethernet, áudio, vídeo, USB de alta velocidade, disco). O DMA pode trabalhar em modos burst (várias transferências consecutivas, monopolizando o barramento) ou cycle stealing (uma transferência por vez, dividindo barramento com a CPU). PCs modernos têm DMA distribuído: cada controlador de periférico (NIC, SATA, NVMe, GPU) tem seu próprio engine de DMA, e o IOMMU media o acesso à memória para segurança.

O PIC18F4550 não tem DMA tradicional como periférico autônomo, mas o módulo USB faz transferências em buffer próprio (descritores BDBuffer Descriptor — em uma região dedicada da SRAM acessada pelo Serial Interface Engine, ou SIE), que conceitualmente é um DMA local. A CPU prepara o descritor, o SIE faz a transferência byte a byte com o host USB, e ao final dispara interrupção sinalizando conclusão.

flowchart LR
  subgraph Polling
    P1[CPU: status?] --> P2{Pronto?}
    P2 -- não --> P1
    P2 -- sim --> P3[Transfere]
  end
  subgraph Interrupção
    I1[CPU: outras tarefas] --> I2[Periférico aciona IRQ]
    I2 --> I3[ISR transfere]
    I3 --> I1
  end

A comparação quantitativa entre polling e interrupção vale a pena fazer com números. Considere um periférico que produz um byte a cada 10 ms (UART a 9600 baud, aproximadamente: 9600 / 10 = 960 bytes/s, ou 1 byte a cada 1,04 ms — vou usar 10 ms para arredondar e exagerar a diferença). Em polling com busy waiting, a CPU passa quase 100% do tempo verificando o flag — a 48 MHz internos, ela faz milhares de leituras de status por byte recebido. Em interrupção, a ISR roda só quando o byte chega: tempo de ISR de 50 ciclos a 48 MHz dá \approx 4{,}2 µs por byte; a 10 ms entre bytes, isso é 0,042% da CPU. Os outros 99,958% ficam livres para o programa principal. Diferença gritante.

Há, no entanto, casos em que polling vence interrupção: quando o periférico é mais rápido que a frequência da CPU consegue tratar interrupções (overhead de salvamento/restauração de contexto comparável ao trabalho útil), e em sistemas de tempo real estritamente determinísticos onde a variabilidade da latência de interrupção (jitter) é inaceitável. Sistemas como controle PID de motor a alta frequência usam polling em malha fechada justamente por isso.

O estudo de caso prático é o display LCD (Liquid Crystal Display, tela de cristal líquido) com controlador HD44780 (chip de controle padrão da indústria para LCDs alfanuméricos; no KIT, é o Sunstar 2004A — 20 colunas por 4 linhas). O HD44780 nasceu em 1985 e ainda domina o mercado de displays simples por inércia gigante: milhões de placas usam, milhões de programadores conhecem o protocolo, milhares de bibliotecas existem.

O chato dele é o timing: o pulso de Enable (sinal de habilitação que valida o dado nos pinos RS, R/W e D0-D7) precisa de pelo menos 230 ns ligado; depois da maioria dos comandos, espera 37 µs; depois de Clear Display (limpa a tela e move cursor para origem) ou Return Home (cursor volta ao início, dado preservado), espera 1{,}52 ms; e a sequência de inicialização é precisa, com bytes específicos em ordem específica. A inicialização canônica em modo 4 bits exige: aguardar 15 ms após power-on para o display estabilizar internamente, enviar 0x30 três vezes com delays de pelo menos 4,1 ms, 100 µs e 100 µs entre eles (para sair de qualquer estado anterior e fixar modo 8 bits provisório), enviar 0x20 para mudar para modo 4 bits, depois 0x28 (function set: 4 bits, 2 linhas, 5x8), 0x08 (display off), 0x01 (clear), 0x06 (entry mode: incremento, sem shift), 0x0C (display on, cursor off). Quem chuta a inicialização tem o famoso “LCD que funciona numa placa e não funciona noutra” — pula um delay e o display entra num estado indefinido que parece funcionar com sorte e dá tela de quadrados pretos sem sorte.

Solução robusta: implementa a inicialização exatamente como no datasheet do HD44780, com os delays mínimos, nunca confie em valores de power-on padrão (alguns clones do HD44780 saem em estados diferentes), e mantém uma rotina lcd_reset() que reinicializa do zero a qualquer momento — útil quando o display recebe ruído elétrico e congela.

A tarefa do módulo é construir o driver completo do LCD em três versões. Primeiro, por polling: inicialização completa, escrita de caracteres, posicionamento (lcd_goto(linha, coluna) que envia o comando Set DDRAM Address), criação de caracteres customizados via CGRAM (Character Generator RAM, RAM de geração de caracteres do controlador — armazena até 8 caracteres customizados 5×8 pixels). Cada caractere customizado é definido por 8 bytes (5 bits cada, padrão dos pixels), escritos sequencialmente em CGRAM via Set CGRAM Address seguido de 8 writes de dado. Aplicações: símbolos especiais (graus, ohm, ícones), pequenas barras de progresso (combinando múltiplos caracteres customizados em sequência horizontal).

Segundo, reescrever para usar interrupção do Timer1 a cada 100 µs, com máquina de estados que avança o protocolo um passo por interrupção; cada estado dispara uma micro-operação (subir Enable, baixar Enable, escrever próximo byte, esperar tempo de propagação) e marca quando o display está “ocupado”. Mede com Stopwatch quanto a CPU ficou livre para outras tarefas. Tipicamente, o driver por interrupção libera 95-99% da CPU para o laço principal, contra os 50-70% do polling com busy waiting.

Terceiro, integrar botões com debounce (filtragem dos ruídos mecânicos da tecla, que ao ser pressionada gera dezenas de transições espúrias nos primeiros milissegundos — o contato físico bate, salta, bate de novo, salta de novo, antes de estabilizar), LEDs e sensores ADC com gerenciamento de prioridade. O algoritmo canônico de debounce: amostra o pino a cada 1-2 ms via ISR de Timer0, mantém um contador integrador por botão que incrementa quando lê pressionado e decrementa quando lê solto, satura em 0 e em um valor máximo (digamos 8); o evento “botão pressionado” só dispara quando o contador atinge o máximo (8 amostras consecutivas pressionado, ou 16 ms estável), evitando ruído. Implementação alternativa via shift register: mantém os últimos 8 valores lidos num byte e dispara o evento quando o byte vira 0xFF (todos pressionados) ou 0x00 (todos soltos).

A arquitetura de device driver (driver de dispositivo) separa três camadas: camada de hardware (acesso a registradores específicos — LATD, LATE, TRISD, TRISE), camada de protocolo (sequências do HD44780, timings, inicialização), e camada de aplicação (lcd_print("Pressao: "), lcd_print_int(p)). Quando você precisar trocar o display (de Sunstar para outro modelo HD44780), só a camada de protocolo se beneficia; quando precisar trocar o microcontrolador (de PIC18 para outro), só a camada de hardware muda. Quando precisar refatorar a aplicação para mostrar coisas diferentes, só a camada de aplicação mexe. Quem programa monolítico misturando as três camadas paga caro na primeira refatoração — código com LATEbits.LATE0 = 1; // pulse E espalhado por dezenas de funções da aplicação é doloroso de manter.

Esse padrão de três camadas reaparece em sistemas operacionais inteiros: o Linux organiza drivers em camada física (acesso ao hardware), camada de protocolo (semântica do dispositivo) e camada de subsistema (interface uniforme para o resto do kernel, como block_device_operations ou tty_operations). Aprender a separação no PIC18 é praticar a habilidade que serve para qualquer escala.

#include <xc.h>
#define LCD_E LATEbits.LATE0

void lcd_enable_pulse(void) {
    LCD_E = 1;
    __delay_us(1);
    LCD_E = 0;
    __delay_us(50);
}
LCD_Pulse:
    bsf     LATE, 0          ; E = 1 (Access Bank implícito p/ SFR)
    call    Delay_1us
    bcf     LATE, 0          ; E = 0
    call    Delay_50us
    return

Módulo 12 — Interrupções, ou “a arte de ser interrompido”

Interrupção é o jeito do hardware avisar a CPU: “para tudo, aconteceu uma coisa que merece atenção agora”. A CPU termina a instrução em curso, salva onde estava (PC, e em alta prioridade também WREG, STATUS, BSR em registradores sombra), salta para uma rotina de tratamento — ISR (Interrupt Service Routine, rotina de serviço de interrupção) —, trata o evento, e retorna com retfie que restaura o contexto e reabilita o GIE. Sem isso, todo programa seria polling — você nunca dormiria, porque a CPU não dormiria, e gastaria 99% do tempo perguntando “alguma coisa nova?”.

A invenção da interrupção é creditada ao DYSEAC, computador da década de 1950 no National Bureau of Standards americano, e popularizou-se com o IBM 7090 no fim dos anos 1950. Foi a primeira abstração temporal séria do nosso campo: o programa principal vive num tempo lógico contínuo, e a interrupção pareia esse tempo lógico com eventos físicos imprevisíveis. Sem ela, sistemas multitarefa não existiriam, drivers de periférico seriam pesadelos, e sistemas em tempo real seriam inviáveis.

No PIC18F4550 o sistema tem dois níveis de prioridade (alta e baixa) e dois vetores fixos: 0x0008 para alta, 0x0018 para baixa. Cada vetor é simplesmente um endereço fixo onde a CPU salta automaticamente quando ocorre a interrupção do nível correspondente; cabe ao programador colocar nesse endereço o início da ISR (ou, mais comumente, um goto para uma ISR localizada em outro lugar mais conveniente). O XC8 cuida disso automaticamente quando você marca funções com __interrupt(high_priority) ou __interrupt(low_priority).

As fontes de interrupção são muitas e merecem ser conhecidas individualmente: timers (Timer0 overflow, Timer1 overflow, Timer2 match com PR2, Timer3 overflow), INT0–INT2 (pinos externos de interrupção 0 a 2, com escolha de borda — subida ou descida — em INTCON2), mudança em RB (RB-change, dispara em transição de qualquer pino RB4-RB7, útil para teclado matricial), ADC (conversão completa), USB (eventos USB do SIE), UART (RX cheio, TX vazio, erro de paridade ou enquadramento), MSSP (Master Synchronous Serial Port, porta serial síncrona mestre, que abriga o SPI e o I²C — eventos de transferência completa ou colisão de barramento), comparadores, EEPROM (escrita completa), high/low voltage detect, oscillator fail monitor. Cada uma é habilitada individualmente em registradores PIE (Peripheral Interrupt Enable, registradores de habilitação de interrupção dos periféricos), com flags em registradores PIR (Peripheral Interrupt Request, registradores de pedido de interrupção, que indicam qual fonte disparou — você precisa ler para identificar).

Habilitação global em INTCON.GIE (Global Interrupt Enable, chave geral) e INTCON.PEIE (Peripheral Interrupt Enable, chave dos periféricos não-core); modo de duas prioridades em RCON.IPEN (Interrupt Priority Enable, do registrador RCON — Reset Control). Quando IPEN = 0, o sistema opera em modo legado de uma só prioridade, com todas as interrupções vetorizando para 0x0008. Quando IPEN = 1, o esquema de duas prioridades é ativado, e cada fonte tem ainda um bit de prioridade individual em IPR1, IPR2, IPR3 — alta (1, padrão) ou baixa (0) — que decide para qual vetor ela aciona. Interrupções de alta prioridade podem interromper handlers de baixa prioridade (aninhamento limitado), mas não vice-versa.

sequenceDiagram
  participant M as Programa principal
  participant H as Hardware/IRQ
  participant I as ISR
  M->>M: executando
  H->>M: dispara IRQ
  M->>I: salva PC, WREG, STATUS, BSR
  I->>I: trata evento
  I->>M: RETFIE: restaura contexto
  M->>M: retoma onde parou

Onde IRQ é Interrupt Request (pedido de interrupção, o sinal elétrico que aciona o mecanismo) e RETFIE é RETurn From Interrupt Enabling, a instrução de retorno de interrupção que reabilita o GIE automaticamente (ele é zerado automaticamente na entrada da ISR para evitar interrupção aninhada não solicitada — a única forma de aninhar é mistura de prioridades).

O salvamento de contexto é parcialmente automático em alta prioridade: registradores sombra cuidam de WREG, STATUS e BSR (cópia física dedicada que captura os valores no momento da entrada e os restaura no retfie). Em baixa prioridade não há registradores sombra, ou se sua ISR usa FSRs ou PRODL/PRODH (registradores que recebem o resultado da multiplicação mulwf), salvar e restaurar é por sua conta, no estilo movff WREG, w_temp no começo e movff w_temp, WREG no fim. Esquecer disso significa corromper o WREG do programa principal silenciosamente — bug clássico que aparece como “comportamento errático que some quando ligo o debugger” (porque o debugger muda o timing, e o erro de corrupção só se manifesta com certas configurações específicas de PC × instrução interrompida).

A latência (tempo do disparo até a primeira instrução útil da ISR) é tipicamente três a cinco ciclos no PIC18 — pequena e previsível, justamente por causa do controle hardwired do Módulo 5. Decomposta: tempo para a instrução atual terminar (até 2 ciclos para um desvio tomado), salto para o vetor (2 ciclos), e prólogo de salvamento (se você acrescentar mais salvamentos manuais, somam-se). A previsibilidade é a alma de sistemas de tempo real: você sabe que o motor não vai parar de receber pulsos PWM porque uma ISR demorou demais. Em CPUs modernas com cache, predição de desvios e execução fora de ordem, a latência de interrupção pode variar de dezenas a centenas de ciclos conforme estado interno — péssimo para tempo real, daí sistemas hard-real-time evitarem essas CPUs ou desabilitarem features especulativas.

Quatro princípios de design de ISR para memorizar como mantra.

ISR curta: a ISR só faz o mínimo (lê o periférico, marca um flag, enfileira um byte) e devolve; o processamento pesado é problema do laço principal. Uma ISR que faz lcd_print("Pressionado!") é crime — escrever no LCD leva 1,52 ms, durante os quais nenhuma outra interrupção é atendida (em modo prioridade única) ou interrupções de baixa prioridade ficam bloqueadas (em modo prioridades). Substitui por flag_botao = 1; return; e deixa o laço principal verificar o flag e atualizar o LCD. Estrutura clássica de design: ISRs como “produtores” de eventos em filas curtas, laço principal como “consumidor” único — variante da arquitetura producer-consumer de sistemas concorrentes.

Compartilhamento: variáveis lidas/escritas tanto pela ISR quanto pelo laço principal são volatile (palavra-chave do C que avisa ao compilador “não otimize o acesso a essa variável, ela pode mudar a qualquer momento, sempre releia da memória”), e leitura de estrutura multi-byte exige seção crítica (desabilita interrupção um instante com bcf INTCON, GIE, lê os bytes consistentes, reabilita com bsf INTCON, GIE). Esquecer volatile num contador atualizado por ISR é o segundo bug mais comum: o compilador otimiza a leitura para fora do laço (cacheia em registrador), e o laço nunca vê a atualização. Esquecer seção crítica em leitura multi-byte produz “torn read”, em que metade dos bytes é do estado antigo e metade do novo, com valores impossíveis aparecendo esporadicamente — debug terrível.

Reentrância (capacidade de uma rotina ser chamada de novo antes da chamada anterior terminar): o PIC18 não suporta nativamente reentrância de ISR no mesmo nível — a única forma de aninhar é mistura de prioridades, com a alta interrompendo a baixa. Assuma que sua ISR não se reentra; chame de dentro dela só funções que você sabe serem seguras nesse contexto. Bibliotecas padrão como printf não são reentrantes; chamar de ISR é receita para corrupção.

Debouncing de botão pertence a uma ISR de Timer0 com amostragem periódica (a cada 1-2 ms), não ao código que lê o botão direto no laço principal. O algoritmo robusto mantém um contador por botão: incrementa quando lê pressionado, decrementa quando lê solto, satura nos extremos; o evento “botão pressionado” só dispara quando o contador atinge o máximo, garantindo que o ruído mecânico não conta como pressões múltiplas. Implementação alternativa via shift register (8 amostras consecutivas armazenadas em um byte, evento dispara quando vira 0xFF ou 0x00) é igualmente válida e às vezes mais barata em ciclos.

A aplicação canônica de tudo isso é a base de tempo. Configura Timer0 com prescaler P (divisor de frequência que precede o timer) e recarga R, e a interrupção dispara a cada

T = T_{cy} \cdot P \cdot (256 - R)

onde T é o intervalo entre duas interrupções consecutivas, T_{cy} é o tempo de um ciclo de máquina, P é o valor do prescaler (1, 2, 4, 8, …, 256), R é o valor inicial carregado no Timer0 de 8 bits (o timer conta de R até 255 antes de estourar), e a subtração 256 - R dá o número de incrementos até o estouro. Exemplo completo: queremos tick de 1 ms a 48 MHz internos. T_{cy} = 4/48 \times 10^6 \approx 83{,}33 ns. Pega P = 16 e queremos T = 1 ms = 10^{-3} s:

256 - R = \frac{T}{T_{cy} \cdot P} = \frac{10^{-3}}{83{,}33 \times 10^{-9} \cdot 16} \approx 750

Mas 750 não cabe em 8 bits do Timer0 (máximo é 255). Solução: usa o Timer0 em modo 16 bits (T0CON.T08BIT = 0), e aí 256 - R vira 65536 - R, com R = 65536 - 750 = 64786. Ou pega o Timer1, que é nativamente 16 bits. Carrega TMR0H = 0xFD; TMR0L = 0x12; (que é 0xFD12 = 64786), seguido de habilitação do Timer0 e da interrupção. Detalhe importante: ao escrever em Timer0 16 bits, escreve sempre TMR0L primeiro (que faz buffer) e TMR0H depois — o hardware atomicamente atualiza os dois.

Soma os ticks numa variável global volatile uint32_t ticks_ms e está pronto: delay_ms(n) { uint32_t t0 = ticks_ms; while (ticks_ms - t0 < n); } sem busy waiting na CPU (o laço principal pode dormir entre ticks, ou fazer outras coisas). Funções de agendamento agendar(callback, em_ms), timeout em serial e medição de duração entre eventos nascem da mesma estrutura. A subtração ticks_ms - t0 é robusta contra overflow do uint32_t (que ocorre a cada 2^{32} ms \approx 49 dias), porque aritmética unsigned é modular: a diferença sempre dá o tempo correto módulo 2^{32}, então enquanto n < 2^{31} a comparação está correta mesmo cruzando overflow.

Cuidado adicional: o handler de Timer0 precisa zerar o flag de interrupção (TMR0IF em INTCON) antes de retornar, ou a interrupção dispara de novo imediatamente, levando a loop infinito de re-entrada. Esse padrão se repete para todas as fontes: ler-tratar-zerar-flag-retornar. Esquecer o zerar é bug clássico que produz CPU travada em 100% de ISR.

As três tarefas do módulo amarram tudo: sistema completo de interrupções de todas as fontes do seu projeto (Timer1 para base de tempo, INT0 para botão de emergência, RB-change para teclado matricial, ADC para conversão automática em background), base de tempo com debouncing implementado como acima, e medição direta da latência (toggle de pino na entrada da ISR contra disparo externo no osciloscópio — você verá exatamente os 4 a 5 ciclos previstos no datasheet, e qualquer divergência é informação valiosa sobre seu prólogo de ISR ou sobre instruções em curso no momento do disparo).

Aprender a projetar com interrupções é talvez a mudança mental mais difícil do semestre. Programação sequencial é linear, fácil de raciocinar; programação com interrupções é concorrente, exige pensar em estados globais, condições de corrida e ordens de eventos. É também a porta de entrada para sistemas operacionais reais (cuja ISR é a base do escalonamento de processos), para drivers de dispositivo (a interface com hardware é toda baseada em interrupções) e para sistemas embarcados industriais críticos. Domina esse módulo e você está preparado para a maior parte do trabalho técnico que sistemas embarcados oferecem na vida profissional.

Síntese

Olha o tamanho do caminho que você fez. Começou com uma distinção que parecia filosófica — arquitetura é o contrato, organização é a implementação — e desceu, módulo após módulo, por cada subsistema que torna esse contrato real: como os números são guardados e o que isso custa (Módulo 2), qual o repertório de instruções e como cada uma é codificada (Módulo 3), qual a anatomia que executa cada instrução (Módulo 4), quem orquestra essa anatomia (Módulo 5), como a memória é organizada em camadas (Módulo 8), como a cache amortiza essas camadas (Módulo 9), como a memória virtual abstrai a física (Módulo 10), como a CPU conversa com o mundo (Módulo 11), e como ela é avisada de eventos (Módulo 12). Em cada parada, o mesmo método: define o conceito, mostra como o PIC18F4550 implementa, calcula o custo na fórmula T = N_{\text{instr}} \cdot \text{CPI} \cdot T_{cy}, e bota o LED para piscar no KIT confirmando a previsão.

Esse método é generalizável. Quando você sair daqui e encontrar um processador novo — RISC-V em FPGA, ARM Cortex-M num projeto comercial, Intel Xeon num servidor — a primeira pergunta a fazer é: qual a ISA? Quais as exceções? Quais as latências por classe? Qual a hierarquia de memória? Como se programam interrupções? As respostas mudam, o vocabulário é o mesmo. Você vai conseguir ler datasheet de microcontrolador desconhecido em poucas horas; vai conseguir interpretar gráficos de desempenho em papers acadêmicos; vai conseguir contribuir em projetos open source com camadas de hardware. Essa transferibilidade é o investimento real do semestre.

Na hora de revisar, pega uma pergunta-chave por módulo. Módulo 1: aplica a prova do programador em três detalhes do PIC (acrescentar instrução mullw32, trocar o clock de 8 para 20 MHz, trocar a SRAM por uma mais rápida com mesmo mapa) e justifica cada classificação como arquitetura ou organização. Módulo 2: calcula C e OV para quatro somas em hex (0xFF+0x01, 0x7F+0x01, 0x80+0xFF, 0x10+0x20), explicando por que dão os flags que dão, e converte um padrão IEEE 754 binário32 dado em hexadecimal para decimal científica. Módulo 3: identifica o modo de endereçamento de cada linha num trecho assembly de oito instruções, com correspondência em C linha a linha. Módulo 4: segue os sinais de addwf f, F ao longo dos quatro estados Q, indicando o que está acontecendo em paralelo na instrução vizinha por prefetch, e calcula o caminho crítico hipotético. Módulo 5: descreve a FSM tratando um desvio tomado e a bolha que sobra, calculando o CPI médio para f_b = 0{,}2, e desenha as transições para decfsz em sucesso e em pulo. Módulo 8: faz \bar{t} variando h de 80% a 99% com t_1 = 1 ns e t_2 = 100 ns, e explica por que a curva é convexa (cada percentual de queda em h tem impacto crescente em \bar{t}). Módulo 9: decompõe um endereço de 16 bits em tag/índice/deslocamento para uma cache de 4 vias com blocos de 8 bytes e 64 conjuntos, e classifica três cenários de miss segundo os 3 Cs. Módulo 10: desenha o mapa de memória do seu projeto, identificando todas as regiões em Flash, SRAM e EEPROM com tamanho e propósito, e estima a vida útil da EEPROM dado um padrão de escrita. Módulo 11: justifica polling versus interrupção para o LCD do KIT com argumentos quantitativos (tempo de CPU livre, latência percebida pelo usuário, complexidade de código). Módulo 12: calcula prescaler e recarga de Timer0 para tick de 1 ms a 48 MHz internos, considerando os modos de 8 e 16 bits, e desenha o diagrama temporal de uma ISR aninhada.

Se travar em alguma, abre o módulo correspondente e relê a seção indicada. Não tem como pular: o módulo seguinte sempre supõe os anteriores. E não se engane com a aparente diversidade dos tópicos — eles são variações sobre um tema único: como organizar bits no tempo e no espaço para construir uma máquina que executa programas previsivelmente. Cada módulo aborda uma faceta dessa organização. Em conjunto, eles te dão o vocabulário e os modelos mentais para raciocinar sobre qualquer sistema computacional, do menor microcontrolador ao maior cluster.

Antes da prova integradora, pega um trecho do seu Projeto Integrador que toca pelo menos três destes módulos (algo como uma ISR de Timer0 atualizando o LCD a partir de uma tabela em Flash) e faz o exercício completo: rotula cada linha do assembly gerado com o modo de endereçamento, conta os ciclos, calcula a latência da ISR e mede com o osciloscópio. O encontro entre teoria e medição é, neste curso, o único critério honesto de que você entendeu de verdade. Quando previsão e medição baterem dentro de 5%, parabéns; quando não baterem, investiga — o erro está em algum lugar (sua fórmula, sua medição, ou um detalhe que você ignorou), e descobrir onde é o que vale o semestre. A engenharia de sistemas embarcados não é decorar fórmulas nem reconhecer mnemônicos — é ter um modelo mental tão preciso da máquina que você prevê o pulso antes do osciloscópio mostrá-lo, e quando o pulso difere da previsão, você corrige um ou o outro até alinharem.