Módulo 06: Pipeline — Conceitos e Implementação Básica

Bem-vindo ao Módulo 06. Escrevi este material como se estivéssemos os dois na frente do quadro, com o kit ACEPIC PRO V8.2 ligado e o MPLAB X aberto. É a versão enxuta do livro do módulo: vou direto ao que importa para você medir, no Timer0 do seu PIC18F4550, o desempenho do seu próprio firmware. Para as demonstrações completas, abra o livro.

O Problema: Como Uma Máquina Conclui Três Instruções no Tempo de Um Sinal

Deixa eu te incomodar com um número. Um processador a 1 GHz tem um ciclo de clock de exatamente um nanosegundo. A luz percorre uns trinta centímetros nesse tempo; dentro do silício, um sinal elétrico anda bem menos. E ainda assim processadores comerciais entregam mais de três bilhões de instruções por segundo. Como uma máquina conclui várias instruções no tempo em que um sinal mal atravessa o próprio chip?

A resposta curta: ela não executa uma instrução por vez do início ao fim. Mantém várias ao mesmo tempo, cada uma numa etapa diferente, como uma linha de montagem mantém vários carros em estações distintas. Isso se chama pipeline, e a versão de dois estágios dele já rodava silenciosamente no PIC18F4550 que você programou no Módulo 05. O objetivo aqui é te levar da intuição da linha de montagem à compreensão quantitativa: calcular, com fórmulas, quanto desempenho o pipeline entrega no caso ideal, por que o caso real fica abaixo, e como medir o ganho efetivo no silício. Te aviso de um erro que eu mesmo cometi quando estudei isso pela primeira vez: confundir latência com throughput. São conceitos quase opostos, e a maioria dos raciocínios errados sobre desempenho nasce dessa confusão. Vamos por partes.

A Ideia do Pipeline: Sobreposição de Trabalho

Imagina a cena da lavanderia. Você tem que lavar, secar e dobrar quatro cargas de roupa, cada etapa levando trinta minutos. Do jeito ingênuo, você termina uma carga inteira antes de começar a próxima: noventa minutos por carga, seis horas no total — e enquanto a primeira carga seca, a máquina de lavar fica parada. A solução que qualquer pessoa apressada descobre sozinha: assim que a primeira carga sai da lavadora para a secadora, bota a segunda para lavar. As três estações trabalham ao mesmo tempo, cada uma numa carga diferente. A primeira carga ainda leva noventa minutos para ficar pronta, mas a partir daí uma carga nova sai a cada trinta minutos, e as quatro terminam em três horas. Cortamos o tempo pela metade sem comprar máquina nem acelerar etapa nenhuma. Guarde dois números: noventa minutos (uma carga isolada) e trinta minutos (o intervalo entre cargas prontas); eles voltam com nomes formais.

flowchart LR
    subgraph Sequencial["Sem sobreposicao (6 h)"]
        direction LR
        L1["Lavar C1"] --> S1["Secar C1"] --> D1["Dobrar C1"] --> L2["Lavar C2"]
    end
    subgraph Pipeline["Com sobreposicao (3 h)"]
        direction TB
        T1["t=30: Lavar C1"]
        T2["t=60: Secar C1<br/>Lavar C2"]
        T3["t=90: Dobrar C1<br/>Secar C2<br/>Lavar C3"]
        T1 --> T2 --> T3
    end
    Sequencial -. mesma latencia<br/>menor throughput .-> Pipeline
Figura 1: A sobreposição não acelera uma carga isolada; ela aumenta a taxa com que cargas saem prontas.

A tradução para o processador é direta. As cargas de roupa são as instruções; as estações são os estágios do pipeline, blocos de hardware que fazem uma fração do processamento. A diferença é a escala: as estações operam em minutos, os estágios em frações de nanosegundo, sincronizados pelo clock. A cada borda de clock, todas as instruções avançam um estágio de uma vez, como se a linha inteira desse um passo coordenado.

Quero desfazer logo uma imagem mental errada. Quando digo que cinco instruções estão “em execução simultânea” num pipeline de cinco estágios, não estou dizendo que o processador executa cinco instruções por ciclo. Cada instrução ainda leva cinco ciclos do começo ao fim. O que ocorre ao mesmo tempo é o trabalho de estágios diferentes sobre instruções diferentes: enquanto o estágio de execução opera sobre a terceira instrução, o de busca já carrega a sétima. Nenhuma instrução anda mais rápido; o que aumenta é a taxa com que elas saem prontas pela ponta final. Essa distinção entre “andar mais rápido” e “sair com mais frequência” é exatamente latência versus throughput, e eu volto nela já já.

Tem uma condição que a analogia satisfaz sem a gente perceber: para duas estações trabalharem ao mesmo tempo, elas precisam ser recursos físicos distintos. No processador, estágios distintos precisam usar hardware distinto — o de busca usa a porta da memória de programa, o de acesso à memória usa a porta da memória de dados. Se houvesse uma única memória com uma única porta, os dois colidiriam, e essa colisão é o hazard estrutural do próximo módulo. É por isso que arquiteturas pipelined favorecem a separação física entre instruções e dados, a organização Harvard que você reconhece de imediato no PIC18F4550: ela nasce, em parte, do desejo de sobrepor busca e execução sem conflito.

O Pipeline Clássico de Cinco Estágios

Por que cinco estágios? Porque é o menor número que separa claramente as tarefas de uma arquitetura load-store: buscar usa a memória de programa; decodificar e ler operandos usa o banco de registradores; calcular usa a ULA; acessar dados usa a memória de dados; escrever o resultado usa de novo o banco. Cinco tarefas, cada uma com seu recurso predominante — e é essa separação que permite que rodem em paralelo sem disputar o mesmo hardware. O número cinco não é sagrado (o PIC usa dois, desktops dos anos 2000 usaram mais de vinte), mas virou o modelo didático canônico.

flowchart LR
    IF["IF<br/>Busca<br/>memoria de programa"] --> R1[["Reg IF/ID"]]
    R1 --> ID["ID<br/>Decodifica<br/>le registradores"]
    ID --> R2[["Reg ID/EX"]]
    R2 --> EX["EX<br/>ULA<br/>calcula e decide desvio"]
    EX --> R3[["Reg EX/MEM"]]
    R3 --> MEM["MEM<br/>Acesso<br/>memoria de dados"]
    MEM --> R4[["Reg MEM/WB"]]
    R4 --> WB["WB<br/>Escreve<br/>banco de registradores"]
Figura 2: Os cinco estágios e os quatro registradores de pipeline que isolam eletricamente um estágio do seguinte.

O estágio IF (Instruction Fetch) busca da memória a instrução apontada pelo contador de programa e já o incrementa — no PIC18F4550, de dois, porque cada instrução ocupa duas posições de byte. Repara numa assimetria com consequências graves: o IF precisa saber qual instrução buscar antes mesmo de a anterior ser decodificada. Ele aposta que a próxima é a do endereço seguinte — aposta que vale a maioria das vezes porque programas são quase sempre sequenciais, mas que falha quando aparece um desvio tomado. É essa aposta otimista, o prefetch que você já viu no Módulo 05, que torna o pipeline rápido no caso comum e cria as penalidades.

O estágio ID (Instruction Decode) interpreta os bits: a unidade de controle examina o opcode e, ao mesmo tempo, o banco de registradores lê os operandos (por isso precisa de pelo menos duas portas de leitura). O estágio EX (Execute) é onde a ULA, do Módulo 03, calcula e onde a condição de um desvio é avaliada. Aqui tem uma sutileza: num pipeline de cinco estágios, a decisão do desvio só fica pronta no terceiro estágio, dois ciclos depois de o desvio entrar — e nesses dois ciclos o IF já buscou duas instruções que, se a aposta falhar, serão descartadas (penalidade de dois ciclos). No PIC, com dois estágios, a distância é mínima e a penalidade se limita a um ciclo.

O estágio MEM (Memory Access) acessa a memória de dados — só cargas e armazenamentos fazem trabalho útil aqui; as demais atravessam sem fazer nada. Esse “tempo morto” é deliberado: manter todas as instruções com o mesmo número de estágios garante que cada uma conclua um ciclo depois da anterior, eliminando o conflito de duas instruções escrevendo no banco no mesmo ciclo. O estágio WB (Write-Back) escreve o resultado de volta no banco; concluído o WB, a instrução deixou o pipeline.

Acompanha o fluxo, com lápis e papel. Cinco instruções, I1 a I5. No ciclo 1, só I1 existe, em IF; no ciclo 2, I1 vai para ID e I2 entra em IF; no ciclo 3, I1 em EX, I2 em ID, I3 em IF. No ciclo 5, o pipeline está cheio: I1 em WB, I2 em MEM, I3 em EX, I4 em ID, I5 em IF. A partir daí, uma instrução sai pronta a cada ciclo — como a carga de roupa saía a cada trinta minutos. Os quatro ciclos iniciais de enchimento, em que nada conclui, são o transiente que pesa em programas curtos.

flowchart TB
    subgraph C1["Ciclo 1"]
        a1["I1: IF"]
    end
    subgraph C2["Ciclo 2"]
        a2["I1: ID"]
        b2["I2: IF"]
    end
    subgraph C3["Ciclo 3"]
        a3["I1: EX"]
        b3["I2: ID"]
        c3["I3: IF"]
    end
    subgraph C5["Ciclo 5 (cheio)"]
        a5["I1: WB"]
        b5["I2: MEM"]
        c5["I3: EX"]
        d5["I4: ID"]
        e5["I5: IF"]
    end
    C1 --> C2 --> C3 --> C5
    a5 -. uma conclusao por ciclo .-> Saida["I1 concluida"]
Figura 3: Enchimento e regime permanente: a partir do ciclo 5, uma conclusão por ciclo.

Registradores de Pipeline e Temporização

A analogia da lavanderia esconde uma dificuldade física: roupa fica onde foi colocada, sinais elétricos não. Os fios que saem do estágio IF são os mesmos que alimentam o ID; se nada os separasse, ao buscar a próxima instrução o IF sobrescreveria os sinais que o ID ainda usa. A solução é colocar, entre cada par de estágios, um banco de registradores que captura os resultados na borda de clock e os mantém estáveis durante o ciclo seguinte. São os registradores de pipeline. Num pipeline de cinco estágios há quatro deles, nas fronteiras IF/ID, ID/EX, EX/MEM e MEM/WB, e cada um carrega não só dados, mas também os sinais de controle ainda não consumidos — eles “andam pelo pipeline” como passageiros que descem em pontos diferentes da linha.

Esses registradores impõem a restrição de temporização que governa a velocidade do processador. A cada ciclo, o sinal precisa atravessar a lógica combinacional de um estágio e chegar estável à entrada do registrador de saída antes da próxima borda. Sendo t_i o atraso da lógica do estágio i e t_{reg} o atraso dos registradores, o período mínimo do clock é

T_{clk} \geq \max_{1 \le i \le k}\,(t_i) + t_{reg}.

O estágio gargalo manda em tudo

O estágio que atinge esse máximo é o gargalo: é ele, e só ele, que determina a frequência máxima. Não adianta ter quatro estágios velozes e um lento — o clock acomoda o lento, e os velozes desperdiçam o tempo que sobra em cada ciclo. Por isso projetistas suam para balancear os estágios: cada nanosegundo de desequilíbrio no gargalo é desperdiçado em todos os outros estágios, em todos os ciclos, durante toda a vida do chip.

Surge então a tentação: se o gargalo é o estágio mais lento, por que não picar tudo em pedaços menores, aumentando k e encurtando o ciclo? Dois freios. O próprio t_{reg}: cada registrador novo soma seu atraso, e a partir de certo ponto o ciclo passa a ser dominado pelos registradores, não pela lógica útil. E, mais importante, pipelines profundos sofrem penalidades maiores quando o prefetch falha — assunto do Módulo 07. A profundidade é um parâmetro com retornos decrescentes, não um botão que se gira à vontade.

Latência e Throughput: A Distinção Que Organiza Tudo

Agora a distinção que prometi. Diante de um processador, dá para fazer duas perguntas que parecem iguais mas são profundamente diferentes. Primeira: quanto tempo leva para uma instrução ser executada do início ao fim? Segunda: quantas instruções o processador conclui por unidade de tempo, em regime permanente? A primeira é sobre latência; a segunda, sobre throughput. Num pipeline de k estágios com período T_{clk}, a latência de uma instrução sem stalls é L = k \cdot T_{clk}; o throughput, com o pipeline cheio, é \Theta = 1 / T_{clk}, uma instrução por ciclo. Repara na assimetria: a latência cresce com o número de estágios, mas o throughput não depende dele, só do período de clock. É isso que resolve o paradoxo da abertura. O pipeline não torna nenhuma instrução mais rápida — dividir em mais estágios até aumenta um pouco a latência por causa dos atrasos dos registradores. O que ele melhora é o throughput, ao encurtar o período de clock. Na lavanderia: a latência de uma carga continua noventa minutos; o que mudou foi o throughput, de uma carga a cada noventa para uma a cada trinta minutos.

Deixa eu tornar isso tangível no seu projeto. Suponha que o sistema leia um sensor de temperatura e atualize o display LCD Sunstar 2004A do kit: quantas atualizações por segundo o display recebe depende do throughput da rotina. Mas se o usuário apertar um botão de emergência ligado ao pino RB0, gerando interrupção, a rapidez da reação não é o throughput da rotina de fundo, e sim a latência do caminho que vai do evento físico até a primeira instrução útil do tratamento. Throughput e latência, no mesmo sistema, respondem a perguntas diferentes — e confundi-las leva a dimensionar errado, como um sistema que processa muitos dados por segundo mas demora a reagir quando alguém aperta o botão.

Quando um fabricante anuncia que um processador “executa instruções k vezes mais rápido” graças a um pipeline de k estágios, ele fala do throughput em condições ideais — jamais da latência de uma instrução isolada. Para uma carga de trabalho real, com desvios e dependências, o ganho efetivo será sempre menor.

Formalizando o Desempenho: CPI, Speedup e o Ganho Ideal

Para calcular ganhos com rigor preciso de uma equação que ligue o tempo total às grandezas mensuráveis — o ponto de partida de toda análise de desempenho:

T_{exec} = N_{instr} \cdot \text{CPI} \cdot T_{clk},

onde N_{instr} é o número de instruções, \text{CPI} é a média de ciclos por instrução e T_{clk} é o período de clock. Ela mostra três alavancas para reduzir o tempo: menos instruções (compilador e ISA), menor período de clock (tecnologia e balanceamento) e menor CPI (organização interna). É no CPI que o pipeline atua de forma mais direta.

flowchart LR
    T["Tempo de execucao"] --> N["N de instrucoes<br/>compilador e ISA"]
    T --> CPI["CPI<br/>organizacao e pipeline"]
    T --> CLK["Periodo de clock<br/>tecnologia e gargalo"]
    CPI --> P["Penalidades (p)<br/>desvios tomados<br/>instrucoes de 2 palavras"]
Figura 4: As três alavancas do tempo de execução e onde o pipeline atua.

Num processador sem pipeline, cada instrução é executada por inteiro antes da próxima começar: se percorre k etapas de um ciclo cada, o CPI é k. Com pipeline ideal, uma instrução conclui por ciclo e o CPI tende a 1, independentemente de k, e é essa queda de k para 1 que produz o ganho. Mas desconfie de “ideal”: o CPI de 1 pressupõe que o pipeline nunca esvazia, que nenhuma instrução depende de resultado de outra ainda em execução e que nenhum desvio quebra o fluxo — nada disso vale plenamente em programa real. O CPI efetivo é sempre maior que 1, e a diferença mede o quanto o pipeline está sendo prejudicado.

Vamos ao speedup. Comparando uma máquina sem pipeline (cada instrução leva k ciclos) com uma pipelined de k estágios, para um programa de N instruções o tempo sem pipeline é proporcional a N \cdot k ciclos; com pipeline, a N + k - 1 (os k-1 ciclos de enchimento mais N de regime). O speedup teórico é

S = \frac{N \cdot k}{N + k - 1}, \qquad \lim_{N \to \infty} S = k.

Um pipeline de k estágios oferece, no melhor caso, ganho de até k vezes, atingido só para programas longos. Tome k=5: num programa de cinco instruções, S = 25/9 \approx 2{,}78, longe do ideal porque o enchimento é quase metade do tempo; em cem instruções, S \approx 4{,}81; em dez mil, praticamente o ideal 5. Em laços que rodam milhões de vezes, como os do seu firmware, o regime permanente domina e o enchimento desaparece na contabilidade.

Antes de seguir, responda no papel: se o seu laço de leitura de sensor roda dez mil vezes, faz diferença otimizar o enchimento do pipeline? E se for uma rotina de inicialização que roda uma única vez na partida? Por que a resposta muda?

O ganho real fica abaixo de k por três causas. O desbalanceamento dos estágios: se um concentra o dobro do atraso médio, o clock dobra e o speedup máximo cai pela metade. O sobrecusto t_{reg}, que não some e cria uma profundidade ótima além da qual aprofundar é contraproducente. E, de longe a mais importante na prática, os hazards: situações em que o pipeline precisa parar, inserir bolhas ou descartar instruções porque uma dependência de dados ou um desvio quebrou a aposta otimista. Os hazards elevam o CPI acima de 1 e são o tema integral do Módulo 07. Modelando a penalidade média por p ciclos por instrução, temos

\text{CPI}_{ef} = 1 + p, \qquad S_{ef} = \frac{k}{1 + p}.

Essa última fórmula é a que você vai usar para confrontar teoria e medição no projeto. Se medir o speedup efetivo e conhecer k, inverte a equação e estima p, o número médio de ciclos de penalidade do seu código: quanto menor o p, mais o programa respeita o fluxo sequencial que o pipeline favorece.

Um cálculo concreto. Suponha um laço de dez instruções por iteração: oito de um ciclo, uma chamada de sub-rotina (dois ciclos) e o desvio de retorno, tomado em quase toda iteração (dois ciclos). São dez instruções e doze ciclos, logo \text{CPI}_{ef} = 12/10 = 1{,}2 — vinte por cento acima do ótimo, por conta das duas instruções penalizadas. Mas cuidado: reduzir o CPI nem sempre reduz o tempo, porque o tempo depende também de N_{instr}. Ao inlinar uma sub-rotina você reduz o CPI mas pode aumentar o número de instruções, e o produto N_{instr} \cdot \text{CPI} pode até crescer. Sempre olhe o produto inteiro, nunca um fator isolado.

O Pipeline do PIC18F4550 na Prática

Hora de aterrissar no chip que você tem na mão. O PIC18F4550 não tem cinco estágios; tem dois, busca e execução, e essa simplicidade deixa você observar os fenômenos do pipeline sem o emaranhado de cinco estágios. A 48 MHz, o ciclo de máquina é

T_{cy} = \frac{4}{48\ \text{MHz}} = 83{,}3\ \text{ns},

o que dá um throughput de pico de doze MIPS para instruções de um ciclo. Esse é o teto que seu benchmark vai tentar abordar.

A aposta otimista da busca se manifesta sempre que o fluxo deixa de ser sequencial. Quando um desvio é tomado, a instrução já pré-buscada — a fisicamente seguinte — não é a que deve ser executada: ela é descartada e o processador gasta um ciclo extra para buscar a do destino. Esse ciclo desperdiçado é a penalidade de desvio, a bolha de pipeline num processador de dois estágios.

flowchart TB
    subgraph Continuo["Fluxo sequencial: 1 instrucao por ciclo"]
        p1["Busca I2<br/>Executa I1"] --> p2["Busca I3<br/>Executa I2"] --> p3["Busca I4<br/>Executa I3"]
    end
    subgraph Desvio["Desvio tomado em I3: 1 ciclo de bolha"]
        q1["Busca I4 (errada)<br/>Executa I3 = desvio"] --> q2["Busca destino<br/>BOLHA: sem execucao util"] --> q3["Executa destino"]
    end
    Continuo -. desvio tomado quebra o prefetch .-> Desvio
Figura 5: No PIC18F4550, cada desvio tomado custa um ciclo de bolha; o desvio não tomado não custa nada.

A penalidade só ocorre quando o desvio é efetivamente tomado. Um desvio condicional cuja condição é falsa não interrompe o fluxo: a instrução pré-buscada é a correta e nenhum ciclo se perde. Essa assimetria é a base de uma otimização que você já viu no Módulo 05 — organizar o código para que o caso frequente caia no ramo não tomado.

A maioria das instruções consome um ciclo; os desvios tomados e as instruções de duas palavras (saltos absolutos, chamadas de sub-rotina) consomem dois. Sendo f_d a fração de instruções que são desvios tomados ou de duas palavras, o CPI médio do PIC é \text{CPI}_{ef} = 1 + f_d. Para código puramente sequencial, f_d = 0 e o CPI atinge o ótimo de 1; conforme a fração de desvios tomados cresce, o CPI degrada linearmente e o throughput cai na mesma proporção.

A primeira tarefa do projeto pede um benchmark para medir o throughput efetivo de diferentes sequências de instruções. A metodologia que recomendo usa o Timer0 como cronômetro de ciclos: sem prescaler, ele incrementa uma vez por ciclo de máquina, então a diferença entre a leitura final e a inicial é o número exato de ciclos do trecho. Três cuidados separam uma medição confiável de uma enganosa: leia TMR0L antes de TMR0H, porque o hardware só trava o byte alto no tampão quando o byte baixo é lido; desconte o sobrecusto da instrumentação medindo a cronometragem em torno de um trecho vazio; e repita o trecho um número grande e conhecido de vezes, dividindo a contagem para diluir o sobrecusto fixo.

O código abaixo mede dois núcleos equivalentes em resultado mas diferentes em perfil de desvio — uma soma sequencial e uma atravessada por desvios frequentes — e a diferença de contagem revela, no silício, a penalidade que a teoria previu. A versão otimizada aplica o desdobramento de laço: processando oito elementos por iteração, o salto de retorno passa a custar uma vez a cada oito, reduzindo a penalidade a um oitavo e aproximando o throughput do teto de doze MIPS.

06_benchmark_throughput.c
/*
 * Modulo 06 - Projeto Integrador
 * Benchmark de throughput no PIC18F4550 (12 MIPS @ 48 MHz).
 *
 * A ideia central: medir quantos ciclos de maquina cada
 * trecho de codigo consome usando o Timer0 como cronometro
 * de ciclos. Como o PIC18F4550 a 48 MHz executa um ciclo de
 * maquina (Tcy) a cada 4 ciclos de oscilador, temos
 * Tcy = 4 / 48 MHz = 83,3 ns. O Timer0 incrementado a cada
 * Tcy nos da a contagem de ciclos diretamente.
 *
 * Comparamos dois nucleos de trabalho equivalentes do ponto
 * de vista do resultado, mas diferentes do ponto de vista do
 * pipeline: um cheio de desvios tomados (fluxo descontinuo,
 * que descarta a instrucao pre-buscada) e outro de fluxo
 * sequencial (que aproveita o prefetch). A diferenca de
 * contagem revela a penalidade de pipeline na pratica.
 */
#include <xc.h>
#include <stdint.h>

#define N_AMOSTRAS  64

volatile uint16_t ciclos_sequencial = 0;
volatile uint16_t ciclos_com_desvios = 0;
volatile uint8_t  buffer[N_AMOSTRAS];

/* Inicia o Timer0 como contador livre de 16 bits, 1:1. */
static void timer0_zera(void) {
    T0CON = 0x88;        /* TMR0ON=1, T08BIT=0, PSA=1 (sem prescaler) */
    TMR0H = 0x00;
    TMR0L = 0x00;
}

static uint16_t timer0_le(void) {
    uint8_t lo = TMR0L;          /* le LOW primeiro: trava HIGH no buffer */
    uint8_t hi = TMR0H;
    return (uint16_t)(((uint16_t)hi << 8) | lo);
}

/*
 * Nucleo A: soma sequencial. O laco e o unico desvio; o
 * corpo e uma cadeia de instrucoes de fluxo continuo que
 * mantem o prefetch sempre util.
 */
static uint16_t soma_sequencial(void) {
    uint16_t acc = 0;
    uint8_t i;
    for (i = 0; i < N_AMOSTRAS; i++) {
        acc = (uint16_t)(acc + buffer[i]);
    }
    return acc;
}

/*
 * Nucleo B: mesma soma, mas cada elemento passa por um teste
 * que, propositalmente, gera desvios tomados com frequencia.
 * Cada desvio tomado custa um ciclo extra de descarte da
 * instrucao ja pre-buscada.
 */
static uint16_t soma_com_desvios(void) {
    uint16_t acc = 0;
    uint8_t i;
    for (i = 0; i < N_AMOSTRAS; i++) {
        if (buffer[i] & 0x01) {        /* metade dos casos, em media */
            acc = (uint16_t)(acc + buffer[i]);
        } else {
            acc = (uint16_t)(acc + buffer[i]);
        }
    }
    return acc;
}

void main(void) {
    uint8_t i;
    volatile uint16_t resultado;

    OSCCON = 0x70;                 /* oscilador interno em alta frequencia */
    TRISB  = 0x00;
    LATB   = 0x00;

    for (i = 0; i < N_AMOSTRAS; i++) {
        buffer[i] = (uint8_t)(i * 7 + 3);
    }

    timer0_zera();
    resultado = soma_sequencial();
    ciclos_sequencial = timer0_le();

    timer0_zera();
    resultado = soma_com_desvios();
    ciclos_com_desvios = timer0_le();

    /* Sinaliza fim do benchmark no LED de RB0. */
    LATBbits.LATB0 = 1;

    while (1) {
        /* parado: os contadores ficam disponiveis para inspecao */
    }
}
06_benchmark_throughput_otim.c
/*
 * Modulo 06 - Projeto Integrador
 * Versao extremamente otimizada do benchmark de throughput.
 *
 * Objetivo de otimizacao: maximizar throughput efetivo do
 * laco de soma reduzindo o numero de desvios tomados por
 * elemento processado. A tecnica central e o loop unrolling
 * (expansao do laco) por fator 8: processamos oito elementos
 * por iteracao, de modo que o unico desvio de controle (o
 * salto de retorno do laco) e amortizado entre oito somas em
 * vez de uma. No PIC18F4550, cada salto de retorno tomado
 * custa um ciclo extra de descarte do prefetch; ao reduzir a
 * contagem de saltos de N para N/8, cortamos a penalidade de
 * pipeline para um oitavo do valor original.
 *
 * Tambem evitamos releitura redundante do indice mantendo um
 * ponteiro que avanca em blocos, e acumulamos em variavel
 * local (mapeada em registrador / acesso rapido), escrevendo
 * o resultado na memoria apenas uma vez ao final.
 */
#include <xc.h>
#include <stdint.h>

#define N_AMOSTRAS  64               /* multiplo de 8 */

volatile uint16_t ciclos_otimizado = 0;
extern volatile uint8_t buffer[N_AMOSTRAS];

static void timer0_zera(void) {
    T0CON = 0x88;
    TMR0H = 0x00;
    TMR0L = 0x00;
}

static uint16_t timer0_le(void) {
    uint8_t lo = TMR0L;
    uint8_t hi = TMR0H;
    return (uint16_t)(((uint16_t)hi << 8) | lo);
}

/*
 * Soma com laco expandido por 8. O corpo e uma cadeia de
 * fluxo continuo (oito somas sem desvio interno), e o unico
 * desvio tomado e o retorno do laco, executado N/8 vezes.
 */
static uint16_t soma_unrolled8(void) {
    uint16_t acc = 0;
    const volatile uint8_t *p = buffer;
    uint8_t blocos = N_AMOSTRAS / 8;
    do {
        acc = (uint16_t)(acc + p[0] + p[1] + p[2] + p[3]
                             + p[4] + p[5] + p[6] + p[7]);
        p += 8;
    } while (--blocos);
    return acc;
}

void main(void) {
    volatile uint16_t resultado;

    OSCCON = 0x70;
    TRISB  = 0x00;
    LATB   = 0x00;

    timer0_zera();
    resultado = soma_unrolled8();
    ciclos_otimizado = timer0_le();

    LATBbits.LATB0 = 1;
    while (1) {
    }
}
06_benchmark_throughput.asm
;-----------------------------------------------------------
; Modulo 06 - Projeto Integrador
; Benchmark de throughput no PIC18F4550 em assembly.
;
; Mede, em ciclos de maquina (Tcy), o custo de dois lacos
; equivalentes: um sequencial (fluxo continuo, prefetch util)
; e outro que executa um desvio tomado por iteracao (descarte
; da instrucao pre-buscada -> 1 ciclo de penalidade).
;
; Timer0 conta Tcy diretamente (sem prescaler), servindo de
; cronometro de ciclos. Le-se TMR0L antes de TMR0H para travar
; o byte alto no buffer de leitura do periferico.
;-----------------------------------------------------------
        LIST    P=18F4550
        #include <p18f4550.inc>

N_AMOSTRAS  EQU     .64

            UDATA_ACS
contador    RES     1               ; indice do laco
acc_lo      RES     1               ; acumulador 16 bits
acc_hi      RES     1
ciclos_lo   RES     1               ; copia da contagem do Timer0
ciclos_hi   RES     1

            CODE
inicio:
        ; --- configura I/O e Timer0 (16 bits, sem prescaler) ---
        clrf    TRISB, ACCESS
        clrf    LATB,  ACCESS
        movlw   0x88                ; TMR0ON=1, T08BIT=0, PSA=1
        movwf   T0CON, ACCESS

        ;================= laco sequencial ===================
        rcall   zera_timer
        movlw   N_AMOSTRAS
        movwf   contador, ACCESS
        clrf    acc_lo, ACCESS
        clrf    acc_hi, ACCESS
seq_loop:
        movlw   0x05                ; corpo de fluxo continuo
        addwf   acc_lo, F, ACCESS
        clrf    WREG, ACCESS
        addwfc  acc_hi, F, ACCESS
        decfsz  contador, F, ACCESS ; desvio: nao tomado ate o fim
        bra     seq_loop
        rcall   le_timer
        movff   ciclos_lo, acc_lo   ; (guarda em qualquer destino util)

        ;============ laco com desvio por iteracao ===========
        rcall   zera_timer
        movlw   N_AMOSTRAS
        movwf   contador, ACCESS
des_loop:
        bra     ramo_tomado         ; desvio incondicional tomado:
ramo_tomado:                        ; descarta a instrucao pre-buscada
        nop                         ; -> 1 ciclo de penalidade/iteracao
        decfsz  contador, F, ACCESS
        bra     des_loop
        rcall   le_timer

        bsf     LATB, 0, ACCESS     ; sinaliza fim no LED de RB0
parado:
        bra     parado

;-----------------------------------------------------------
; Subrotina: zera o Timer0
;-----------------------------------------------------------
zera_timer:
        clrf    TMR0H, ACCESS
        clrf    TMR0L, ACCESS
        return

;-----------------------------------------------------------
; Subrotina: le o Timer0 (TMR0L primeiro trava TMR0H)
;-----------------------------------------------------------
le_timer:
        movff   TMR0L, ciclos_lo
        movff   TMR0H, ciclos_hi
        return

        END

De posse da contagem de ciclos C de um trecho que executou N_{instr} instruções, o throughput efetivo é \Theta = N_{instr} / (C \cdot T_{cy}) e o CPI efetivo é C / N_{instr}. Confrontar esse CPI medido com o previsto pela fração de desvios é o cerne do exercício: quando coincidem, você entendeu o modelo; quando divergem, investigar a divergência ensina mais do que a coincidência.

A terceira tarefa pede a identificação de todas as instruções do seu código que causam stalls ou bolhas, com estimativa do impacto total. O código a seguir percorre um traço de eventos, classifica cada um pelo custo segundo o modelo do PIC e contabiliza o total de bolhas, exibindo-o nos bits baixos do PORTD para inspeção visual no kit. A classificação segue o datasheet: fluxo sequencial e desvios não tomados custam um ciclo sem bolha; desvios tomados e instruções de duas palavras custam dois, dos quais o segundo é a bolha.

06_traco_pipeline.c
/*
 * Modulo 06 - Projeto Integrador
 * Identificacao de instrucoes que causam bolhas no pipeline
 * de busca/execucao do PIC18F4550.
 *
 * Este codigo NAO simula um pipeline de cinco estagios; ele
 * exercita, de forma controlada, as situacoes que no modelo
 * classico causariam stalls e que no PIC18F4550 real causam
 * o descarte da instrucao pre-buscada (a "bolha" de um ciclo).
 *
 * Tres situacoes sao isoladas:
 *   1. Desvio condicional TOMADO  -> 1 ciclo de penalidade
 *   2. Desvio condicional NAO TOMADO -> sem penalidade
 *   3. Instrucao de 2 palavras (ex.: GOTO, CALL, MOVFF) ->
 *      sempre 2 ciclos, pois ocupa duas posicoes de programa
 *
 * A funcao percorre um vetor de eventos e contabiliza, para
 * cada categoria, o numero de ciclos de penalidade estimado,
 * produzindo o relatorio que o entregavel do modulo exige.
 */
#include <xc.h>
#include <stdint.h>

typedef enum {
    EV_SEQUENCIAL = 0,   /* instrucao de 1 palavra, fluxo continuo */
    EV_DESVIO_TOMADO,    /* desvio condicional que sera tomado     */
    EV_DESVIO_NAO_TOMADO,/* desvio condicional nao tomado          */
    EV_DUAS_PALAVRAS     /* GOTO/CALL/MOVFF: ocupa 2 ciclos        */
} evento_t;

#define N_EVENTOS  10

const evento_t trace[N_EVENTOS] = {
    EV_SEQUENCIAL, EV_DESVIO_NAO_TOMADO, EV_DESVIO_TOMADO,
    EV_SEQUENCIAL, EV_DUAS_PALAVRAS,     EV_DESVIO_TOMADO,
    EV_SEQUENCIAL, EV_DESVIO_NAO_TOMADO, EV_DUAS_PALAVRAS,
    EV_SEQUENCIAL
};

volatile uint16_t ciclos_estimados = 0;
volatile uint8_t  n_bolhas = 0;

/*
 * Retorna o custo em ciclos de maquina de cada categoria de
 * evento, segundo o modelo de temporizacao do PIC18F4550.
 * As bolhas correspondem ao ciclo extra alem do ciclo base.
 */
static uint8_t custo_em_ciclos(evento_t ev, uint8_t *bolha) {
    switch (ev) {
        case EV_DESVIO_TOMADO:
            *bolha = 1;
            return 2;             /* 1 ciclo base + 1 de descarte */
        case EV_DUAS_PALAVRAS:
            *bolha = 1;
            return 2;             /* 2 palavras -> 2 ciclos       */
        case EV_DESVIO_NAO_TOMADO:
        case EV_SEQUENCIAL:
        default:
            *bolha = 0;
            return 1;             /* 1 ciclo, prefetch aproveitado */
    }
}

void main(void) {
    uint8_t i, bolha;
    uint16_t total = 0;
    uint8_t  bolhas = 0;

    TRISD = 0x00;
    LATD  = 0x00;

    for (i = 0; i < N_EVENTOS; i++) {
        total = (uint16_t)(total + custo_em_ciclos(trace[i], &bolha));
        bolhas = (uint8_t)(bolhas + bolha);
    }

    ciclos_estimados = total;
    n_bolhas = bolhas;

    /* Exibe o numero de bolhas nos 4 bits baixos de PORTD. */
    LATD = (uint8_t)(bolhas & 0x0F);

    while (1) {
    }
}
06_traco_pipeline.asm
;-----------------------------------------------------------
; Modulo 06 - Projeto Integrador
; Demonstracao das tres situacoes de penalidade no fluxo de
; busca/execucao do PIC18F4550, em assembly.
;
;   1. Desvio NAO tomado    -> 1 ciclo  (prefetch aproveitado)
;   2. Desvio TOMADO        -> 2 ciclos (descarte do prefetch)
;   3. Instrucao 2 palavras -> 2 ciclos (GOTO/CALL/MOVFF)
;
; O contador 'n_bolhas' acumula o numero de ciclos de
; penalidade (bolhas) e e exibido nos 4 bits baixos de PORTD.
;-----------------------------------------------------------
        LIST    P=18F4550
        #include <p18f4550.inc>

            UDATA_ACS
n_bolhas    RES     1
amostra     RES     1

            CODE
inicio:
        clrf    TRISD,  ACCESS
        clrf    LATD,   ACCESS
        clrf    n_bolhas, ACCESS

        ;--- Situacao 1: desvio condicional NAO tomado ------
        ; STATUS,Z = 0 -> bz nao salta -> sem bolha
        movlw   0x01
        movwf   amostra, ACCESS
        tstfsz  amostra, ACCESS     ; amostra != 0: nao pula
        bra     sem_bolha_1         ; fluxo continuo, 1 ciclo
sem_bolha_1:

        ;--- Situacao 2: desvio incondicional TOMADO --------
        bra     destino_2           ; tomado: descarta prefetch
        nop                         ; instrucao pre-buscada descartada
destino_2:
        incf    n_bolhas, F, ACCESS ; +1 bolha (ciclo de descarte)

        ;--- Situacao 3: instrucao de 2 palavras (CALL) -----
        call    sub_dummy, FAST     ; 2 ciclos: ocupa 2 palavras
        incf    n_bolhas, F, ACCESS ; +1 bolha (ciclo extra)

        movf    n_bolhas, W, ACCESS
        andlw   0x0F
        movwf   LATD, ACCESS        ; exibe bolhas em PORTD<3:0>
parado:
        bra     parado

sub_dummy:
        return  FAST

        END

Construindo Diagramas de Pipeline à Mão

A segunda tarefa do projeto pede uma visualização esquemática do fluxo de instruções pelo pipeline. Existem ferramentas automáticas, mas insisto que você faça os primeiros diagramas à mão: quem só usa a ferramenta nunca desenvolve a intuição de prever, antes de simular, em que ciclo cada instrução estará em cada estágio — e é essa intuição que separa quem entende o pipeline de quem apenas o descreve.

O método é uma receita de papel. Liste as instruções na vertical e os ciclos na horizontal; para a primeira instrução, escreva os estágios em sequência, um por ciclo; para cada seguinte, repita deslocando um ciclo à direita. Em qualquer coluna do regime permanente aparecem os cinco estágios distintos, cada um de uma instrução diferente. Se a terceira instrução for um desvio tomado, as duas desenhadas depois precisam ser apagadas, e o destino só começa seu IF após a resolução; as colunas sem instrução útil são as bolhas. Comece pelo modelo de dois estágios do PIC e só então generalize para o de cinco, que sua documentação pode apresentar como referência conceitual.

Comparando com Arquiteturas de Referência

O pipeline não nasceu com microcontroladores: o IBM 7030 Stretch, de 1961, já sobrepunha busca e execução, e o CDC 6600, de Seymour Cray em 1964, levou o paralelismo de unidades funcionais a um patamar inédito. Mas foi com as arquiteturas load-store dos anos 1980, sobretudo o MIPS, que o pipeline de cinco estágios se cristalizou no modelo canônico — por isso é informalmente chamado de pipeline MIPS clássico. A tabela abaixo dá ordem de grandeza das escolhas de profundidade.

Profundidade de pipeline em famílias representativas
Família ou processador Profundidade típica Característica predominante
PIC18F4550 2 estágios Baixo custo, throughput modesto, latência previsível
MIPS clássico (didático) 5 estágios Referência, equilíbrio entre clareza e desempenho
ARM Cortex-M (embarcado) 3 estágios Tempo real, baixo consumo, resposta previsível
Desktop dos anos 2000 mais de 20 estágios Alta frequência, penalidades de desvio severas
RISC-V de aplicação geral de 5 a mais de 10 Configurável conforme o ponto de operação

A lição é a mesma do Módulo 05 sobre controle hardwired versus microprogramado: não existe profundidade universalmente melhor. O PIC, com dois estágios, sacrifica throughput de pico em favor de simplicidade, baixo consumo e latência altamente previsível — uma virtude subestimada. Num sistema de controle, muitas vezes você não precisa que a rotina seja a mais rápida; precisa que leve sempre o mesmo número de ciclos, para sincronizá-la com eventos externos, e um pipeline raso sem mecanismos especulativos oferece exatamente isso. Não foi o caso dos desktops do início dos anos 2000, que apostaram em pipelines de mais de vinte estágios e esbarraram no consumo de energia e na penalidade de desvio — num pipeline de vinte estágios, um desvio mal previsto desperdiça vinte ciclos. O ganho que a profundidade prometia era devorado pelas penalidades que ela mesma ampliava, e as gerações seguintes recuaram para pipelines mais curtos com previsão de desvios, que você verá nascer no Módulo 07.

Síntese: Da Linha de Montagem ao Timer0

Voltemos ao paradoxo da abertura. Uma máquina conclui várias instruções no tempo de um sinal porque não as executa uma por vez; mantém várias em estágios diferentes ao mesmo tempo, aumentando o throughput sem reduzir a latência de cada instrução. Se houver um único conceito que eu gostaria que sobrevivesse na sua memória, é essa separação entre latência e throughput, e a constatação de que o pipeline troca um aumento modesto da primeira por um aumento substancial da segunda. Tudo o mais foi elaboração técnica disso: a equação T_{exec} = N_{instr} \cdot \text{CPI} \cdot T_{clk}, o speedup ideal que tende a k, e o efetivo S_{ef} = k/(1+p) que sempre fica abaixo por causa das penalidades. As três tarefas do projeto decorrem direto disso: o benchmark de throughput se apoia na cronometragem por Timer0, modelando o CPI do PIC como 1 + f_d; a visualização do fluxo, no traçado manual; a identificação de stalls e bolhas, no código de análise de traço. Registre cada medição no diário referenciando as definições do módulo.

E fica a promessa em aberto: ao derivar o ganho ideal, assumi que o pipeline nunca para, que nenhuma instrução depende de resultado de outra ainda em execução, que nenhum desvio quebra o fluxo. Essas suposições são falsas em programa real, e é o desmoronamento delas que produz as penalidades. O Módulo 07 enfrenta isso de frente, com os hazards e as técnicas para mitigá-los. Como preparação, examine um trecho do seu código e identifique dois casos: uma instrução que usa o resultado calculado pela anterior, e um desvio cujo destino só é conhecido após uma comparação. Pergunte-se o que o pipeline faria se não pudesse parar nem prever — chegar ao próximo módulo com as perguntas certas já formuladas é um dos investimentos mais rentáveis que você fará nesta disciplina.