Meses atrás, no artigo Quando a lógica encontra a física, comentei que estava participando de um campeonato cujo objetivo era desenvolver um processador baseado em RISC-V. Naquela época, estávamos em uma etapa muito mais próxima dos blocos fundamentais e do fluxo de projeto do que de uma CPU completa: escrevendo hardware sintetizável, validando circuitos e acompanhando o caminho entre uma descrição lógica e algo que, eventualmente, poderia virar silício. Alguns meses depois, aqui estamos nós: a equipe Design Microchip Supreme — DMS. O Supreme foi ideia do Pedro. Desculpa, Pedro, mas continuo achando que o Supreme estragou o nome.

No momento em que escrevo este texto, ainda não terminamos o desenvolvimento do chip desta fase da competição e, por motivos relativamente óbvios, não pretendo transformar este artigo em documentação pública do nosso projeto enquanto ainda estamos competindo. A proposta é outra: registrar algumas das decisões, dificuldades, descobertas e curiosidades que apareceram quando conceitos com os quais eu já tinha bastante familiaridade precisaram deixar de existir como peças relativamente isoladas e passar a funcionar juntos dentro de uma mesma microarquitetura. Antes do campeonato eu já conhecia ALU, registradores, multiplexadores, máquinas de estado, barramentos, ISA, Assembly e vários dos problemas que aparecem quando software encontra hardware; trabalhar com firmware para microcontroladores praticamente obriga você a conviver com boa parte disso. O que mudou não foi descobrir que essas peças existem. Foi ter que fazê-las concordar entre si, ciclo após ciclo, até aquilo realmente se comportar como um processador.

Antes de ADD ser ADD

Existe algo curioso na história da computação: a vontade de construir máquinas capazes de executar operações matemáticas é muito mais antiga do que o processador digital como conhecemos hoje. Durante séculos apareceram calculadoras mecânicas baseadas em engrenagens, rodas e alavancas; mais tarde, computadores analógicos passaram a representar grandezas matemáticas por meio de tensões, correntes e outros fenômenos físicos. É nesse contexto que o nome amplificador operacional fica especialmente interessante. Esses circuitos foram amplamente utilizados em computadores analógicos para realizar operações como soma, integração e diferenciação. Antes de ADD ser uma sequência de bits entrando em um decoder, uma soma podia literalmente emergir do comportamento elétrico de um circuito configurado para realizá-la.

Eu gosto dessa história porque ela reduz um pouco a aura de magia que colocamos sobre computadores modernos. No fundo, continuamos tentando fazer matéria representar e manipular abstrações matemáticas; o que mudou brutalmente foi o nível de abstração disponível para quem projeta essas máquinas. Hoje eu posso escrever algo tão inocente quanto result = a + b; em Verilog e deixar que uma cadeia enorme de ferramentas transforme essa descrição em uma implementação composta por elementos digitais muito mais básicos. A soma não deixa de existir fisicamente. Eu apenas não preciso desenhar, à mão, cada porta que vai fazê-la existir.

Eu achei que teria que desenhar tudo na mão

Essa foi uma das minhas primeiras surpresas com o campeonato. Eu já sabia o que era uma HDL, conhecia Verilog e entendia o papel da síntese, mas ainda carregava uma imagem meio exagerada de que desenvolver um processador significaria passar uma quantidade absurda de tempo construindo o circuito quase porta por porta: multiplexadores, flip-flops, somadores, comparadores e fios até alguma coisa parecida com uma CPU surgir. Eu esperava sofrimento. Recebi sofrimento. Só estava errado sobre o tipo.

HDLs como Verilog existem justamente para permitir que a gente trabalhe em um nível de abstração muito mais alto. Em vez de desenhar individualmente cada porta necessária para uma operação, descrevemos comportamento, relações entre sinais, registradores, lógica combinacional e máquinas de estado, e depois ferramentas de síntese transformam essa descrição em uma rede de hardware. Isso facilita enormemente a vida, mas traz uma armadilha curiosa: Verilog se parece o suficiente com uma linguagem de programação para, de vez em quando, tentar convencer você de que aquilo é software. Até o hardware lembrar você. Um if não significa necessariamente uma decisão que será executada depois da linha anterior, como em C; dois blocos podem representar circuitos existindo e reagindo simultaneamente; uma variável pode representar conexão ou estado; e um trecho perfeitamente válido do ponto de vista sintático ainda pode sintetizar para um circuito bastante diferente daquele que você imaginou.

A mudança, para mim, foi menos aprender novos componentes e mais passar a pensar continuamente em estrutura, simultaneidade e estado. O código não está simplesmente dando ordens para uma máquina pronta. Em boa parte do tempo, ele está descrevendo a própria máquina.

Afinal, o que é uma CPU?

Se eu tivesse que definir uma Central Processing Unit, ou CPU, de maneira deliberadamente simples, diria que ela é uma máquina programável capaz de buscar uma instrução, interpretar o que aquela instrução significa, executar a operação correspondente e continuar repetindo esse processo. Conceitualmente, não parece tão assustador. Então podemos imaginar um processador extremamente sofisticado chamado Yuri Processing Unit, ou YPU. Por enquanto, o YPU entende apenas duas instruções: LER e SOMAR.

LER VALOR_1
LER VALOR_2
SOMAR VALOR_1, VALOR_2

O comportamento esperado parece evidente: ler VALOR_1, ler VALOR_2 e somar os dois. Pronto. Temos um processador. Só que essa explicação dura pouco quando começamos a fazer perguntas inconvenientes. Onde VALOR_1 está? Quando a CPU lê um valor, onde ela guarda esse valor? Como ela sabe que uma determinada sequência de bits significa SOMAR? Como decide quais operandos entram na operação? Onde coloca o resultado? Como sabe onde está a próxima instrução? E quem coordena tudo isso para que nenhuma parte resolva fazer a coisa certa no momento errado?

flowchart LR
    PC["Program Counter"] --> IMEM["Memória de instruções"]
    IMEM --> DEC["Decoder / Controle"]
    IMEM --> RF["Registradores"]
    RF --> MUX["Seleção de operandos"]
    DEC --> MUX
    DEC --> ALU["ALU"]
    MUX --> ALU
    ALU --> RF
    ALU --> PC

Uma visão propositalmente simplificada. O objetivo aqui não é representar a microarquitetura real da equipe, e sim mostrar como as caixinhas começam a depender umas das outras.

É nesse ponto que aquelas caixas bonitas dos diagramas deixam de ser apenas caixas. Precisamos de registradores, caminhos de dados, uma unidade capaz de realizar operações, alguma forma de acessar memória, um contador para acompanhar o fluxo de execução, lógica para interpretar os bits de cada instrução e sinais de controle dizendo para cada bloco o que deve acontecer naquele momento. “Ler dois números e somar” continua sendo uma ideia simples. Fazer uma máquina repetir isso corretamente, para dezenas de instruções diferentes e sem destruir o próprio estado no caminho, já é outra história.

Multiplexadores: escolher também é computar

Um dos componentes que aparece o tempo inteiro nesses caminhos é o multiplexador, ou MUX. A ideia é simples: existem várias entradas possíveis e um sinal de seleção decide qual delas será encaminhada para a saída. Se a ALU pode receber como segundo operando tanto o valor de um registrador quanto um valor imediato extraído da instrução, por exemplo, algum circuito precisa decidir qual dessas duas coisas chega até ela naquele ciclo; esse circuito pode ser um multiplexador. O mesmo raciocínio aparece para escolher o próximo valor do Program Counter, a origem de um dado que será escrito no banco de registradores ou qual resultado deve seguir adiante dentro do datapath.

Multiplexação, porém, está longe de ser uma ideia exclusiva de processadores. Imagine um sistema com quatro sensores analógicos e apenas um conversor analógico-digital: em vez de manter quatro ADCs, é possível selecionar qual sensor será conectado ao conversor a cada instante. Um seletor de entradas de áudio ou vídeo segue uma ideia parecida. Em telecomunicações, diferentes formas de multiplexação permitem que múltiplos fluxos compartilhem um mesmo meio físico. A implementação muda, a escala muda e às vezes até o domínio muda de digital para analógico, mas a pergunta fundamental continua parecida: entre várias coisas que poderiam passar por aqui, qual deve passar agora?

flowchart TB
    subgraph Mundo_real["Fora da CPU"]
        S1["Sensor A"] --> AMUX{"MUX analógico"}
        S2["Sensor B"] --> AMUX
        S3["Sensor C"] --> AMUX
        SEL1["Seleção"] --> AMUX
        AMUX --> ADC["ADC"]
    end

    subgraph CPU["Dentro da CPU"]
        REG["Valor de registrador"] --> DMUX{"MUX"}
        IMM["Immediate"] --> DMUX
        PCV["Program Counter"] --> DMUX
        CTRL["Controle"] -->|seleção| DMUX
        DMUX --> ALU["ALU"]
    end

Talvez por isso eu tenha começado a enxergar os MUX como uma espécie de pontuação da arquitetura. No diagrama, eles parecem pequenos seletores entre caminhos. Na prática, cada um existe porque em algum momento tivemos que tomar uma decisão sobre o fluxo dos dados. E cada novo caminho possível é também uma nova oportunidade para o sinal de seleção estar errado.

Registradores: memória curta, estado e tempo

Se os multiplexadores escolhem caminhos, os registradores permitem que a CPU preserve estado. De forma simplificada, um registrador é um conjunto de elementos capazes de armazenar bits. Alguns são definidos pela própria ISA e ficam visíveis para quem programa, como os registradores de propósito geral; outros têm funções especiais, como o Program Counter; e outros ainda existem apenas por decisão da microarquitetura, guardando resultados intermediários, estados de controle ou dados que precisam sobreviver até o próximo ciclo. O ponto em comum é que eles permitem que o circuito se lembre de alguma coisa depois que a lógica combinacional ao redor mudou.

Falar em estado nos obriga a falar de clock. Em um circuito síncrono, normalmente queremos que as principais mudanças de estado aconteçam de maneira coordenada por uma borda de clock. A lógica combinacional pode passar parte do ciclo calculando o próximo valor, mas o registrador só captura esse valor quando ocorre a borda escolhida, frequentemente a borda de subida. Em muitos bancos de registradores, por exemplo, a escrita pode ser síncrona enquanto a leitura é combinacional, dependendo da implementação. Também existem sinais assíncronos, como determinados resets, capazes de afetar o estado independentemente de uma borda do clock. Essa distinção é importante porque um sistema síncrono ganha previsibilidade justamente ao organizar boa parte das mudanças em instantes bem definidos; tudo aquilo que chega de forma assíncrona precisa ser tratado com cuidado para não introduzir comportamentos difíceis de reproduzir ou problemas de temporização.

No Verilog, isso aparece de maneira bastante concreta. Em lógica sequencial disparada por clock, atribuições não bloqueantes, com <=, costumam ser a escolha correta porque expressam atualizações de estado que devem ocorrer de forma coordenada após a avaliação daquele evento. Em lógica combinacional, atribuições bloqueantes, com =, normalmente expressam melhor os cálculos que formam o valor combinacional daquele bloco. Não é apenas estilo: confundir esses modelos pode fazer a simulação representar uma ordem que não corresponde à intenção de hardware.

always @(posedge clk) begin
    if (reset)
        pc <= 32'b0;
    else
        pc <= next_pc;
end

Esse trecho não significa “execute esta linha depois da anterior” como em um programa tradicional. Ele descreve estado: na borda de subida do clock, o registrador associado ao pc captura um novo valor. É uma diferença pequena na sintaxe e enorme na forma de pensar. Quando vários registradores fazem isso juntos, o circuito inteiro parece dar um pequeno passo no tempo.

Uma instrução é só um número com autoestima

No RISC-V, uma instrução RV32 possui 32 bits. Para nós, ela pode aparecer como add x5, x6, x7; para o processador, aquilo é apenas um vetor de bits cujo significado depende de como cada campo é interpretado. Em uma instrução do tipo R, por exemplo, encontramos campos como funct7, rs2, rs1, funct3, rd e opcode. O opcode ajuda a identificar a classe geral da instrução; rs1 e rs2 indicam registradores de origem; rd aponta para o destino; e funct3 e funct7 ajudam a distinguir operações que compartilham parte da codificação.

31..25 24..20 19..15 14..12 11..7 6..0
funct7 rs2 rs1 funct3 rd opcode

Quando você olha para isso apenas como ISA, é um formato de instrução. Quando precisa implementar o decoder, cada campo passa a significar extrações de bits, comparações e sinais de controle que precisam concordar com o restante do datapath. A parte interessante não foi descobrir a existência desses campos — eu já os conhecia — mas perceber o quanto determinadas escolhas da ISA influenciam diretamente a forma mais conveniente de construir o circuito que vai interpretá-las.

O registrador que ganha para não fazer nada

Uma das minhas pequenas decisões favoritas do RISC-V é o x0. Ele é um registrador cujo valor é permanentemente zero. Você pode tentar escrever nele o quanto quiser; ele continuará zero. addi x0, x0, 42 é uma forma particularmente sofisticada de não conseguir guardar 42 em lugar nenhum. À primeira vista, reservar um registrador para não armazenar nada pode parecer desperdício, mas ter um zero sempre disponível simplifica várias operações, permite descartar resultados simplesmente usando x0 como destino e ajuda a representar várias pseudo-instruções sem aumentar desnecessariamente a ISA.

Para quem escreve Assembly, isso vira uma conveniência. Para quem implementa o banco de registradores, vira uma regra concreta: leituras de x0 precisam produzir zero e escritas destinadas a x0 precisam ser ignoradas. É um bom exemplo de algo que aparece muitas vezes nesse tipo de projeto: uma frase curta na especificação pode se transformar em uma responsabilidade muito específica dentro do hardware.

funct7, ou como sete bits também ganham personalidade

Outro detalhe que ganha peso quando entramos na implementação é o funct7. Algumas operações compartilham o mesmo opcode e parte dos mesmos campos, então o decoder precisa olhar para combinações de opcode, funct3 e funct7 para descobrir o que realmente está sendo pedido. Isso significa que decodificar uma instrução está longe de ser apenas converter um número no nome bonito de uma operação. A decisão precisa virar ações concretas: quais registradores serão lidos, qual fonte alimentará a ALU, qual operação a ALU deverá executar, se haverá escrita no banco de registradores, se existe acesso à memória e como o Program Counter será atualizado.

É aí que um erro pequeno ganha consequências grandes. Interpretar incorretamente alguns bits não produz apenas uma legenda errada; pode fazer o dado certo percorrer o caminho errado, ou o dado errado percorrer o caminho certo, que é um tipo de bug particularmente educado porque às vezes entrega um resultado plausível antes de destruir sua tarde.

Quem espalhou os bits do immediate pelo chão?

E então existem os immediates. Algumas instruções carregam parte de seus operandos dentro dos próprios 32 bits, e seria intuitivo imaginar que esses bits estivessem sempre juntos, em ordem, esperando para serem lidos. Em alguns formatos a situação é amigável. Em outros, especialmente nos formatos usados por branches e jumps, a primeira impressão é que alguém deixou o immediate cair no chão e recolocou os pedaços onde encontrou espaço.

Em um branch, por exemplo, o immediate é reconstruído a partir de campos distribuídos pela instrução. Essa organização parece estranha para quem olha o encoding como ser humano, mas existe uma razão arquitetural importante: manter campos como rs1, rs2 e funct3 em posições consistentes simplifica outros caminhos de decodificação, e certos bits são posicionados de forma a favorecer a implementação e a codificação dos deslocamentos. O que parece desorganizado de um ponto de vista pode ser uma escolha bastante deliberada de outro.

31 30..25 24..20 19..15 14..12 11..8 7 6..0
imm[12] imm[10:5] rs2 rs1 funct3 imm[4:1] imm[11] opcode

Sim, o imm[11] está ali mesmo.

Essa foi uma sensação recorrente no campeonato: conceitos que eu já conhecia ganhavam uma segunda leitura quando precisavam entrar em um circuito real. Antes eu olhava para um formato de instrução e via a especificação. Agora, ao mesmo tempo, eu também vejo extensão de sinal, concatenação, comparadores, MUXes e sinais de controle que terão de produzir exatamente aquele comportamento.

O mesmo 11111111 pode ter duas personalidades

Outra fonte de diversão são os valores signed e unsigned. Fisicamente, um registrador não armazena “um número negativo” usando um material diferente de “um número positivo”; ele armazena bits. O significado vem da interpretação. Em 8 bits, 11111111 pode representar 255 quando tratado como unsigned ou -1 quando interpretado como signed em complemento de dois. O vetor é o mesmo. A semântica muda, e é justamente essa mudança de semântica que começa a se espalhar pelo circuito quando a ISA possui operações que distinguem os dois casos.

Comparações signed e unsigned exigem raciocínios diferentes. Extensões precisam preservar o significado correto. Um shift aritmético não é a mesma coisa que um shift lógico. E as multiplicações tornam essa diferença particularmente visível. Em uma multiplicação de dois operandos de 32 bits, o produto completo pode exigir 64 bits; quando precisamos apenas da parte baixa, alguns casos signed e unsigned acabam produzindo os mesmos 32 bits inferiores. Quando queremos a parte alta, porém, a interpretação dos operandos passa a importar diretamente. Instruções como MULH, MULHU e MULHSU existem justamente para combinações diferentes de signed e unsigned, e de repente um detalhe aparentemente semântico vira uma decisão concreta sobre extensão de sinal, largura de operandos e quais bits do resultado serão preservados.

Foi um daqueles problemas que primeiro precisava estar correto na lógica da minha cabeça para depois poder ficar correto no Verilog. Escrever $signed ou $unsigned não resolve uma intenção mal definida. Antes eu preciso saber qual largura cada operando deve ter, como deve ser estendido e qual interpretação eu quero preservar. E o Verilog ainda oferece oportunidades adicionais de tropeço: a signedness de expressões pode mudar conforme concatenações, slices, constantes e casts entram na conta. Confiar demais em conversões implícitas é uma forma eficiente de escrever algo que funciona nos testes óbvios e falha precisamente naquele valor de borda que ninguém lembrou de experimentar. Nessa parte do projeto, explicitar largura e signedness deixou de parecer verbosidade e passou a funcionar como documentação do circuito que eu realmente queria construir.

Quatro multiplicações e um registrador

Foi justamente nas instruções de multiplicação que apareceu uma das decisões de arquitetura de que mais gostei. Vale a observação: multiplicação não faz parte do RV32I puro; ela pertence à extensão M do RISC-V. Durante a implementação, eu poderia seguir um caminho mais caro em hardware e manter estruturas de armazenamento independentes associadas às diferentes variantes de multiplicação, ou compartilhar um único registrador de tamanho fixo para guardar o resultado completo utilizado por essas operações. Em termos bastante simplificados: eu podia gastar mais recursos e deixar cada caminho mais independente, ou economizar hardware e aumentar a responsabilidade da lógica de controle.

Escolhi compartilhar o registrador. Se as diferentes operações não precisam manter seus resultados simultaneamente, replicar o armazenamento pode significar gastar área sem uma necessidade imediata. Só que “usar menos hardware” nunca significa “ganhar de graça”. Um recurso compartilhado precisa ser arbitrado e coordenado. A FSM precisa garantir que ninguém consuma um valor velho ou sobrescreva o recurso no momento errado; a decisão pode limitar algum paralelismo futuro; e uma otimização que parece ótima olhando apenas para quantidade de registradores pode ficar menos atraente quando timing, roteamento, potência ou evolução da microarquitetura entrarem na conversa.

Essa decisão resumiu bem uma mudança de perspectiva que o projeto reforçou em mim. A pergunta raramente é apenas “qual solução funciona?”. Normalmente é “qual compromisso queremos assumir?”. Área, consumo, frequência, complexidade de controle, facilidade de verificação e possibilidade de expansão competem entre si. Um diagrama pronto mostra o resultado da escolha; construir a CPU faz você viver as razões pelas quais aquela escolha precisou existir.

Datapath: por onde os bits passeiam

Até aqui falei de ALU, registradores, multiplexadores, immediates e vários outros elementos como peças reconhecíveis. Quando passamos a olhar para os caminhos que efetivamente carregam, transformam e armazenam os dados entre essas peças, chegamos ao datapath. Ele é, de forma simplificada, a parte da microarquitetura por onde os valores circulam: os operandos saem do banco de registradores, passam por seletores, entram na ALU, podem acessar memória, retornam para algum registrador e, dependendo da instrução, influenciam até o próximo valor do Program Counter. Se a ISA descreve o que uma instrução significa, o datapath ajuda a responder por onde os dados precisam passar para que esse significado aconteça.

Uma analogia que funciona razoavelmente bem é imaginar uma cidade com ruas já construídas. Os dados são os carros; registradores, memória e ALU são lugares pelos quais eles podem passar; os multiplexadores são entroncamentos que escolhem rotas. Só que as ruas não sabem sozinhas para onde cada carro precisa ir. Falta algo dizendo quais caminhos estarão abertos, quando um valor pode ser armazenado e qual operação deve acontecer naquele momento.

A FSM é o maestro que ninguém vê

Na nossa implementação, boa parte dessa coordenação é feita por uma máquina de estados finitos, ou FSM (Finite State Machine). Uma FSM representa o controle como um conjunto finito de estados e regras de transição. Em uma CPU multiciclo, isso é particularmente útil porque uma instrução pode exigir ações diferentes ao longo de vários ciclos: em um momento buscamos ou preparamos uma instrução, em outro interpretamos seus campos, depois configuramos os caminhos necessários no datapath e, em algum instante, registramos o resultado. Não vou expor aqui a sequência real de estados da nossa implementação, mas a ideia importante é que existe uma lógica dizendo em que momento da execução estamos e, com base nisso, quais ações podem acontecer.

flowchart LR
    FSM["FSM / Unidade de controle"] -->|"seletores, enables, operação"| DP["Datapath"]
    DP -->|"flags, comparações, término"| FSM

    subgraph D["Datapath"]
        PC2["PC"] --> MUX2{"MUXes"}
        RF2["Registradores"] --> MUX2
        MUX2 --> ALU2["ALU"]
        ALU2 --> RF2
    end

FSM e datapath acabam funcionando como duas metades da mesma conversa. O datapath contém os recursos e caminhos capazes de realizar as operações; a FSM produz sinais de controle que selecionam esses caminhos, habilitam escritas, escolhem operações e determinam quando o estado pode avançar. O datapath, por sua vez, devolve informações que podem influenciar o controle, como resultados de comparações, condições de branch ou indicação de término de alguma operação. Quando os dois concordam, uma instrução progride de forma organizada. Quando não concordam, você abre a waveform.

Abaixo está uma visão conceitual de como uma FSM multiciclo pode organizar a execução. Ela não representa os estados reais da nossa implementação; serve apenas para mostrar a ideia de que a CPU não precisa fazer tudo de uma vez.

stateDiagram-v2
    [*] --> Buscar
    Buscar --> Decodificar
    Decodificar --> Executar
    Executar --> Finalizar
    Finalizar --> Buscar

“Do meu lado está funcionando”

Existe uma frase especialmente perigosa em qualquer projeto desenvolvido por várias pessoas: “do meu lado está funcionando”. Nós somos quatro pessoas construindo partes de um sistema que, no final, precisa se comportar como uma única máquina. Isso significa que não basta cada módulo estar correto isoladamente. Duas pessoas podem implementar módulos perfeitamente válidos e ainda assim produzir, juntas, um sistema incorreto. Uma interface pode ser interpretada de duas formas diferentes; alguém pode assumir que um sinal permanece válido durante um ciclo inteiro enquanto outra parte espera apenas um pulso; uma mudança aparentemente local pode quebrar uma hipótese antiga de outro bloco. Às vezes o bug não está em nenhum dos dois lados da conexão. Ele está exatamente na maneira como os dois lados entendem a conexão.

Talvez por influência da minha experiência com software, uma das coisas em que mais investi desde cedo foi na infraestrutura ao redor do processador. Montei um ambiente virtual reproduzível com o ferramental necessário para desenvolver e testar o projeto, reduzindo o máximo possível o clássico “na minha máquina funciona”. Organizamos o projeto em Git e criamos uma pipeline de CI que executa automaticamente uma suíte de testbenches. Cada alteração relevante volta a testar funcionalidades que já existiam. Se uma instrução nova quebrar silenciosamente algo que funcionava há duas semanas, queremos descobrir isso no commit que introduziu o problema, não depois de mais quinze mudanças em cima dele.

flowchart LR
    DEV["Alteração no projeto"] --> GIT["Git"]
    GIT --> CI["CI"]
    CI --> TB["Suíte de testbenches"]
    CI --> FW["Firmware de teste em Assembly"]
    TB --> CHECK{"Passou?"}
    FW --> CHECK
    CHECK -->|Sim| OK["Segue o jogo"]
    CHECK -->|Não| WAVE["Abra a waveform e descubra quem mentiu"]

Se é possível quebrar, a gente tenta

Além dos testbenches específicos, mantemos também um arquivo em Assembly que funciona como uma espécie de firmware de validação do próprio processador. Ele cresce junto com a CPU. Cada nova instrução ou funcionalidade implementada entra também nesse programa, e a ideia não é apenas testar o caminho feliz. Tentamos combinar comportamentos, reutilizar resultados, forçar valores de borda e criar sequências que possam expor interações inesperadas. Uma soma pode funcionar perfeitamente isolada e falhar depois de determinada sequência; um branch pode passar em um caso óbvio e quebrar quando depende de um resultado anterior; uma mudança no decoder pode corrigir uma família de instruções e introduzir regressão em outra.

Com o tempo, a pergunta deixa de ser apenas “essa instrução funciona?” e passa a ser “de quantas formas diferentes conseguimos fazê-la falhar?”. Quando construímos algo tão dependente de estado, temporização e interação entre blocos, verificar cada módulo isoladamente é só a primeira camada. O comportamento interessante aparece quando as instruções começam a conviver. A suíte de regressão e o firmware de teste viraram, portanto, parte do desenvolvimento, não uma etapa separada que só acontece depois de alguém declarar o processador “pronto”.

Essa também foi uma parte divertida da fronteira entre software e hardware. Git, automação, integração contínua e testes de regressão não pertencem exclusivamente ao mundo do software; o objeto sendo testado é diferente, mas o princípio continua extremamente valioso. Cada mudança deveria trazer alguma evidência de que o comportamento esperado continua existindo.

Onde a verdade se esconde entre dezenas de sinais

Quando alguma coisa inevitavelmente quebra, começa uma das atividades mais características do processo: abrir as waveforms e tentar reconstruir, ciclo a ciclo, aquilo que o processador acreditava estar fazendo. O Program Counter estava apontando para a instrução correta? A memória entregou os bits esperados? O decoder interpretou opcode, funct3 e funct7 corretamente? rs1 e rs2 apontavam para os registradores certos? O immediate foi reconstruído direito? A FSM estava no estado esperado? A ALU recebeu os operandos corretos? O resultado voltou para rd? E, talvez mais importante que todas essas perguntas, isso aconteceu no ciclo em que deveria acontecer?

Esse último ponto parece trivial escrito assim, mas é uma das diferenças mais importantes entre pensar apenas na lógica da operação e pensar em hardware. Não basta um valor estar correto; ele precisa estar correto na hora certa. Um sinal perfeito chegando um ciclo atrasado continua sendo um bug. Um resultado correto escrito antes da hora também. Uma alteração de estado no instante errado pode produzir uma consequência que só aparece várias instruções depois. Waveforms acabam tendo uma qualidade quase investigativa: você começa com o crime — o firmware produziu um resultado impossível — e volta no tempo procurando o primeiro instante em que a realidade do processador divergiu da realidade que você esperava. Em algum ponto, um bit tomou uma decisão questionável. O trabalho é descobrir qual.

Conhecer as peças não é construir o sistema

Antes do campeonato, eu já sabia o que era uma ALU, um banco de registradores, um Program Counter, uma unidade de controle, multiplexadores e máquinas de estado. Já tinha estudado formatos de instrução, escrito Assembly, trabalhado diretamente com registradores de microcontroladores e convivido o suficiente com hardware para entender vários desses conceitos. O valor dessa experiência não foi me apresentar uma lista nova de componentes; foi me obrigar a enxergar as relações entre componentes que eu já conhecia. Uma ALU isolada é relativamente simples de entender. Um decoder isolado também. Um banco de registradores, um MUX ou uma FSM idem. A dificuldade aparece quando a instrução correta precisa ser buscada, interpretada, encaminhada para os operandos certos, executada pela unidade correta, ter seu resultado enviado ao destino adequado e atualizar o estado da máquina no momento preciso — para que, imediatamente depois, tudo possa recomeçar sem carregar um erro do ciclo anterior.

Talvez essa seja a ideia que melhor resume o que aprendi até aqui: um processador não é difícil apenas porque possui muitas peças; ele é difícil porque todas essas peças precisam concordar sobre a realidade ao mesmo tempo. O decoder precisa interpretar a instrução da mesma forma que o datapath espera; o banco de registradores precisa fornecer os dados que os caminhos selecionados realmente consomem; a FSM precisa saber quando cada escrita é permitida; o Program Counter precisa avançar ou desviar de acordo com o que acabou de acontecer; e o ambiente de verificação precisa ser capaz de perceber quando qualquer uma dessas relações deixou de ser verdadeira. Eu já conhecia os instrumentos. O campeonato me colocou no meio da orquestra.

Depois que o RTL funciona, a física começa a cobrar

A CPU ainda não está pronta, e fazer o comportamento lógico funcionar é apenas parte do caminho. Um dos próximos passos é transformar o conhecimento que hoje ainda está muito concentrado em RTL, comentários, testbenches, waveforms e na cabeça de quem participou das decisões em documentação de verdade: interfaces, sinais, estados relevantes, fluxo de dados, convenções, decisões arquiteturais e as razões pelas quais certas escolhas foram feitas. Documentação não tem a mesma emoção de adicionar uma instrução nova, mas um sistema que só pode ser compreendido por quem estava presente enquanto ele surgia já começa a acumular uma dívida perigosa.

Depois disso vem a parte que conecta diretamente este texto ao artigo anterior: levar o projeto pelo fluxo de síntese e implementação física no OpenLane. Até aqui boa parte do nosso raciocínio está concentrada em correção funcional: a instrução certa produz o resultado certo? A FSM avança corretamente? Os testbenches passam? Quando o projeto entra no fluxo físico, essas perguntas continuam válidas, mas deixam de ser suficientes. Passamos a nos preocupar com floorplanning, placement, árvore de clock, routing, extração de parasitas e análise de timing. O circuito precisa não apenas estar correto; precisa caber, ser roteável e conseguir operar dentro das restrições de frequência e potência que escolhermos.

flowchart LR
    RTL["RTL / Verilog"] --> SYN["Síntese"]
    SYN --> FP["Floorplan"]
    FP --> PLC["Placement"]
    PLC --> CTS["Clock Tree"]
    CTS --> RT["Routing"]
    RT --> RC["Extração RC"]
    RC --> STA["Timing / STA"]
    STA --> PPA["Área, potência e performance"]

É aí que a física devolve para o projeto boa parte das abstrações que o Verilog gentilmente escondeu. Interconexões têm resistência e capacitância; células têm atrasos; fanout e carga importam; o clock não chega magicamente a todos os lugares no mesmo instante; variações de processo, tensão e temperatura alteram o comportamento; e parasitas que não apareciam na descrição RTL entram na conta depois do layout. Também precisamos olhar para fenômenos ligados à alimentação, como quedas de tensão, e para o efeito que condições elétricas menos ideais podem ter sobre margens e desempenho. Dois blocos que pareciam vizinhos perfeitos no código podem acabar fisicamente separados por uma distância que passa a ter consequência real.

Uma das consequências mais diretas é o timing. Entre duas bordas de clock existe um tempo finito para que um valor atravesse a lógica combinacional e esteja estável antes de ser capturado pelo próximo elemento sequencial. Se o caminho for lento demais, podemos ter violações de setup; se a relação temporal depois da borda não for respeitada, podemos ter violações de hold. Isso cria uma situação que eu particularmente gosto como resumo de projeto digital: um circuito pode estar logicamente correto e fisicamente errado. O testbench passa, a função booleana está certa e, ainda assim, a implementação pode não conseguir operar de forma confiável na frequência que você gostaria.

Também teremos que olhar com muito mais cuidado para performance, área, potência e eficiência energética. Aumentar a frequência pode tornar os caminhos críticos mais difíceis e elevar consumo. Paralelizar hardware pode reduzir ciclos e ao mesmo tempo aumentar área, capacitância e atividade de comutação. Compartilhar recursos — como no caso do registrador usado pelas multiplicações — pode economizar silício, mas também criar caminhos mais complexos ou reduzir oportunidades de paralelismo. Uma escolha que parece claramente eficiente quando observada apenas no RTL pode ganhar uma segunda interpretação quando relatórios de área, timing e potência entram na conversa. É justamente aí que as decisões tomadas hoje deixam de ser apenas ideias elegantes e começam a receber uma nota da física.

E é por isso que ainda considero este texto um relato de meio de caminho. Algumas decisões que hoje parecem boas provavelmente serão questionadas quando tivermos relatórios de síntese, timing, área e potência na frente. Algum caminho crítico pode revelar que fomos otimistas demais. Alguma estrutura pode ocupar mais área do que imaginávamos. Um detalhe aparentemente irrelevante de roteamento pode obrigar uma revisão de arquitetura. Algum testbench ainda vai encontrar uma combinação que ninguém previu, alguma waveform ainda vai me fazer reconsiderar escolhas de vida e existe uma probabilidade estatisticamente relevante de o Pedro tentar colocar Supreme no nome de mais alguma coisa.

Quando entrei no campeonato, eu não estava começando do zero em arquitetura de computadores. Eu já conhecia muitas das peças e já tinha convivido com boa parte dos conceitos que aparecem neste texto. A experiência mudou outra coisa: a forma de enxergar o sistema inteiro. Verilog abstrai uma quantidade enorme do trabalho que seria necessário para construir um circuito porta por porta, mas não abstrai as decisões de engenharia. Na verdade, quando ALU, registradores, MUXes, FSM, decoder, temporização e verificação precisam funcionar juntos, essas decisões ficam mais visíveis do que nunca.

Existe também um próximo passo que torna tudo isso especialmente concreto: na próxima fase do campeonato, vamos levar o projeto para uma FPGA real. Até aqui, o processador vive principalmente no RTL, nos testbenches e nas waveforms. Na FPGA, vamos sintetizar e implementar esse circuito em hardware reconfigurável, conectar os sinais necessários e observar o nosso processador executando de verdade. É o momento em que aquilo que hoje é uma descrição de hardware começa a ganhar vida física.

FPGA significa Field-Programmable Gate Array, ou matriz de portas programável em campo. Ela é um chip cheio de blocos lógicos, registradores, memória e interconexões que podem ser configurados depois da fabricação. Em vez de apenas simular o comportamento de uma CPU em um computador, nós carregamos uma configuração que transforma fisicamente esses recursos em uma CPU funcionando em paralelo, com clock, sinais elétricos e limitações reais. A FPGA é importante justamente por ocupar esse espaço entre a descrição RTL e um chip fabricado: permite testar a arquitetura em hardware, observar o comportamento do sistema completo e alterar o projeto sem fabricar um novo silício.

E há algo quase absurdo nisso: o mesmo pedaço de matéria pode virar um processador, um controlador de vídeo, uma interface de comunicação ou um acelerador especializado, dependendo da configuração que carregamos. Não estamos apenas executando um programa sobre um hardware pronto; estamos usando um programa para definir que hardware existirá. É uma forma meio louca de programação, porque o resultado não é apenas uma sequência de instruções — é uma máquina inteira ganhando forma.

E ainda dá para complicar muito mais

Mesmo depois de fazer uma CPU multiciclo funcionar, ainda estamos longe de esgotar as possibilidades de uma arquitetura. Um próximo passo poderia ser dividir a execução em um pipeline, permitindo que diferentes instruções ocupem estágios diferentes ao mesmo tempo. Isso pode aumentar o throughput, mas também cria novos problemas: dependências entre instruções, hazards de dados, hazards de controle em branches e a necessidade de mecanismos de stall, forwarding ou predição de desvio.

Também seria possível adicionar múltiplos cores, o que introduziria questões de comunicação, sincronização, compartilhamento de memória e coerência de cache. Falando em cache, uma hierarquia de memória poderia reduzir o custo dos acessos mais frequentes, mas exigiria lidar com misses, políticas de substituição, escrita e consistência dos dados. A partir daí, uma MMU poderia traduzir endereços virtuais para endereços físicos, usando tabelas de páginas e uma TLB para manter traduções recentes. Memória virtual, proteção entre processos e diferentes níveis de privilégio aumentariam ainda mais a responsabilidade do hardware.

E o processador também precisa conversar com o mundo externo. Adicionar interfaces de I/O, controladores, interrupções e GPIO faria a CPU interagir com periféricos e dispositivos reais, mas exigiria definir protocolos, endereçamento, temporização e formas seguras de atravessar fronteiras entre hardware e software. Cada uma dessas extensões é perfeitamente possível; nenhuma delas é apenas “mais um bloco”. Elas adicionam estados, caminhos, regras e interações que precisam continuar coerentes.

É fácil olhar para uma CPU simples e imaginar que basta continuar empilhando funcionalidades até chegar a um processador moderno. Na prática, cada camada torna as anteriores mais difíceis de verificar, otimizar e explicar. Um pipeline afeta hazards; hazards afetam o controle; caches afetam o comportamento da memória; a MMU afeta os endereços; I/O e interrupções afetam o fluxo de execução. A arquitetura pode ficar muito mais complexa — e é justamente essa escalada que torna tão interessante perceber como poucas peças já são suficientes para ensinar tanto.

No fim, 32 bits parecem muito pouco.

Até você precisar decidir o significado de cada um deles — e depois convencer a física a concordar.