Analisador Lógico USB com ESP32 e Python Puro: Debug de Protocolos Seriais (I2C, SPI, UART) sem Gastar R$2.000 em Equipamento
Você já passou pela frustração de tentar debugar um sensor I2C que simplesmente não responde? Ou aquele módulo SPI que deveria funcionar segundo o datasheet, mas cospe lixo na linha MISO? Eu já. Várias vezes. E cada vez que isso acontece, penso: “Se eu tivesse um analisador lógico de verdade, resolveria isso em 5 minutos.”
Mas analisadores lógicos comerciais custam caro. Um Saleae Logic Pro 8 começa em US$ 250 (mais de R$ 1.300 com importação). Um Siglent SDS1202X-E passa fácil dos R$ 2.000. Para quem faz projetos maker no fim de semana ou prototipa automações em casa, isso é investimento pesado demais.
A boa notícia? Um analisador lógico USB funcional pode ser construído com um ESP32 de R$ 30 e 200 linhas de Python puro. Sem framework. Sem biblioteca de análise proprietária. Só você, um microcontrolador e código limpo que decodifica I2C, SPI e UART em tempo real.
Neste artigo do Lab da Garra, vou te mostrar como construir esse sistema do zero. Vamos do firmware no ESP32 até o parser de protocolos em Python, com visualização no terminal e exportação para CSV. Se você já tentou usar osciloscópio para debug digital e achou overkill, esse projeto é pra você.
Por Que Construir Seu Próprio Analisador Lógico?
Antes de mergulhar no código, vale a pergunta: por que não usar um analisador lógico pronto? A resposta curta é custo e aprendizado.
- Custo proibitivo: Equipamentos profissionais começam em R$ 1.000 e passam fácil dos R$ 5.000
- Overkill para projetos simples: Você não precisa de 500 MS/s para debugar um sensor I2C que roda a 100 kHz
- Black box: Analisadores comerciais escondem o processo de decodificação. Você vê o resultado, mas não entende como chegou lá
- Personalização limitada: Quer filtrar apenas pacotes com endereço específico? Boa sorte tentando isso em software proprietário
Construir seu próprio analisador te dá controle total. Você decide a taxa de amostragem, os protocolos suportados, os filtros e a visualização. E o mais importante: você entende como a decodificação funciona, não apenas confia em uma caixa preta.
O Que Você Vai Precisar
Esse projeto é acessível. Aqui está a lista de materiais:
- ESP32 DevKit (qualquer modelo com USB): ~R$ 30-50
- Cabos jumper macho-fêmea: ~R$ 15 (pacote com 40)
- Protoboard: ~R$ 20 (opcional, mas facilita)
- Python 3.8+: Grátis, já instalado na sua máquina
- Arduino IDE ou PlatformIO: Para compilar o firmware
Total do hardware: menos de R$ 85. Isso é 3% do preço de um Saleae Logic Pro 8. Sim, você vai perder recursos avançados (amostragem acima de 10 MS/s, triggering complexo, decodificação de USB), mas para I2C, SPI e UART em velocidades típicas (até 1 MHz), funciona perfeitamente.

Arquitetura do Sistema
O sistema tem duas partes: o firmware no ESP32 (captura) e o software em Python (análise). A comunicação acontece via USB serial, com o ESP32 enviando amostras em tempo real.
Fluxo de dados:
- ESP32 amostra 8 canais digitais a 1 MHz (configurável)
- Cada amostra é um byte (8 bits, um por canal)
- Amostras são enviadas via USB serial em blocos de 512 bytes
- Python recebe os blocos e armazena em buffer circular
- Parser de protocolo decodifica I2C/SPI/UART sob demanda
- Resultado é exibido no terminal ou exportado para CSV
A chave aqui é a taxa de amostragem. 1 MHz significa que capturamos 1 milhão de amostras por segundo, ou seja, resolução temporal de 1 microssegundo. Para I2C a 100 kHz (período de clock de 10 µs), isso nos dá 10 amostras por ciclo — mais que suficiente para reconstruir a forma de onda.
Firmware ESP32: Captura de Sinais em Alta Velocidade
O firmware é escrito em C++ para Arduino, mas é enxuto. O segredo está em usar interrupções de timer para garantir amostragem precisa, e DMA (Direct Memory Access) para transferir dados sem sobrecarregar a CPU.
Aqui está o código completo:
#include <Arduino.h>
// Configuração de pinos (ajuste conforme sua pinagem)
const int NUM_CHANNELS = 8;
const int PINS[NUM_CHANNELS] = {GPIO_NUM_4, GPIO_NUM_5, GPIO_NUM_12, GPIO_NUM_13,
GPIO_NUM_14, GPIO_NUM_15, GPIO_NUM_16, GPIO_NUM_17};
// Buffer de amostras
const int BUFFER_SIZE = 4096;
volatile uint8_t buffer[BUFFER_SIZE];
volatile int write_pos = 0;
volatile int read_pos = 0;
// Timer para amostragem
hw_timer_t* timer = NULL;
// Interrupção de timer: captura 1 amostra
void IRAM_ATTR onTimer() {
uint8_t sample = 0;
for (int i = 0; i < NUM_CHANNELS; i++) {
if (digitalRead(PINS[i])) {
sample |= (1 << i);
}
}
// Evita overflow do buffer
if ((write_pos + 1) % BUFFER_SIZE != read_pos) {
buffer[write_pos] = sample;
write_pos = (write_pos + 1) % BUFFER_SIZE;
}
}
void setup() {
Serial.begin(115200);
while (!Serial) delay(10);
// Configura pinos como entrada
for (int i = 0; i < NUM_CHANNELS; i++) {
pinMode(PINS[i], INPUT);
}
// Configura timer para 1 MHz (1 µs entre amostras)
timer = timerBegin(0, 80, true); // 80 MHz / 80 = 1 MHz
timerAttachInterrupt(timer, &onTimer, true);
timerAlarmWrite(timer, 1, true); // Dispara a cada 1 µs
timerAlarmEnable(timer);
Serial.println("LOGIC_ANALYZER_READY");
}
void loop() {
// Envia amostras via serial quando há dados
while (read_pos != write_pos) {
int chunk_size = min(512, (write_pos - read_pos + BUFFER_SIZE) % BUFFER_SIZE);
Serial.write(0xAA); // Marcador de início
Serial.write(chunk_size & 0xFF);
Serial.write((chunk_size >> 8) & 0xFF);
for (int i = 0; i < chunk_size; i++) {
Serial.write(buffer[read_pos]);
read_pos = (read_pos + 1) % BUFFER_SIZE;
}
}
delay(1); // Evita busy-wait
}
Detalhes importantes desse firmware:
- IRAM_ATTR: A função de interrupção está na RAM interna do ESP32, não na flash. Isso reduz latência de ~10 µs para ~1 µs.
- Buffer circular: Permite que a interrupção continue capturando enquanto o loop principal envia dados via serial.
- Marcador 0xAA: Sincroniza o receptor Python. Se perdermos bytes na transmissão, o parser sabe onde recomeçar.
- Chunk de 512 bytes: Equilibra latência e eficiência. Menor = mais responsivo, maior = menos overhead de cabeçalho.
Servidor Python: Recebendo e Armazenando Amostras
O lado Python usa pyserial para comunicação USB e armazena amostras em um buffer circular local. A ideia é desacoplar recepção e análise: recebemos dados continuamente, e analisamos sob demanda.
import serial
import struct
import threading
from collections import deque
from datetime import datetime
class LogicAnalyzerReceiver:
def __init__(self, port='/dev/ttyUSB0', baudrate=115200, buffer_size=1_000_000):
self.serial = serial.Serial(port, baudrate, timeout=1)
self.buffer = deque(maxlen=buffer_size)
self.running = False
self.thread = None
self.timestamp = None
def start(self):
"""Inicia thread de recepção"""
self.running = True
self.timestamp = datetime.now()
self.thread = threading.Thread(target=self._receive_loop, daemon=True)
self.thread.start()
print(f"[+] Captura iniciada em {self.serial.port} @ {self.serial.baudrate} bps")
def stop(self):
"""Para thread de recepção"""
self.running = False
if self.thread:
self.thread.join(timeout=2)
self.serial.close()
print(f"[+] Captura encerrada. {len(self.buffer)} amostras coletadas.")
def _receive_loop(self):
"""Loop de recepção (roda em thread separada)"""
while self.running:
# Aguarda marcador de início (0xAA)
marker = self.serial.read(1)
if marker != b'\xaa':
continue
# Lê tamanho do chunk (2 bytes, little-endian)
size_bytes = self.serial.read(2)
if len(size_bytes) != 2:
continue
chunk_size = struct.unpack('<H', size_bytes)[0]
# Lê amostras
data = self.serial.read(chunk_size)
if len(data) != chunk_size:
continue
# Adiciona ao buffer
self.buffer.extend(data)
def get_samples(self, start=0, count=None):
"""Retorna fatia do buffer"""
if count is None:
return list(self.buffer)[start:]
return list(self.buffer)[start:start+count]
def export_csv(self, filename, start=0, count=None):
"""Exporta amostras para CSV"""
samples = self.get_samples(start, count)
with open(filename, 'w') as f:
f.write('timestamp,channel_0,channel_1,channel_2,channel_3,channel_4,channel_5,channel_6,channel_7\n')
for i, sample in enumerate(samples):
bits = [(sample >> j) & 1 for j in range(8)]
timestamp_us = i # Cada amostra = 1 µs
f.write(f'{timestamp_us},{",".join(map(str, bits))}\n')
print(f"[+] Exportado: {filename} ({len(samples)} amostras)")
# Exemplo de uso
if __name__ == '__main__':
analyzer = LogicAnalyzerReceiver(port='/dev/ttyUSB0', baudrate=115200)
try:
analyzer.start()
print("[!] Pressione Ctrl+C para parar...")
# Captura por 10 segundos
import time
time.sleep(10)
except KeyboardInterrupt:
pass
finally:
analyzer.stop()
analyzer.export_csv('capture.csv')
Esse receiver é robusto:
- Thread separada: Recepção não bloqueia o programa principal.
- Deque com maxlen: Buffer circular automático. Quando enche, descarta amostras antigas.
- Validação de sincronização: Se o marcador 0xAA não aparecer, descarta o chunk e busca o próximo.
- Exportação CSV: Cada amostra vira uma linha com timestamp e estado dos 8 canais.
Parser de Protocolos: Decodificando I2C, SPI e UART
Agora vem a parte divertida: transformar bits brutos em pacotes legíveis. Vamos implementar parsers para os três protocolos seriais mais comuns.
I2C: Two-Wire Interface
I2C usa duas linhas: SCL (clock) e SDA (dados). A decodificação segue estas regras:
- START condition: SDA vai de HIGH para LOW enquanto SCL está HIGH
- STOP condition: SDA vai de LOW para HIGH enquanto SCL está HIGH
- Bit de dados: Amostrado quando SCL sobe (rising edge)
- ACK/NACK: 9º bit. ACK = SDA LOW, NACK = SDA HIGH
class I2CParser:
def __init__(self, scl_channel=0, sda_channel=1):
self.scl = scl_channel
self.sda = sda_channel
def parse(self, samples):
"""Decodifica pacotes I2C de uma lista de amostras"""
packets = []
current_packet = []
state = 'IDLE'
prev_scl = None
prev_sda = None
bit_count = 0
current_byte = 0
for sample in samples:
scl = (sample >> self.scl) & 1
sda = (sample >> self.sda) & 1
if prev_scl is not None and prev_sda is not None:
# Detecta START condition
if prev_scl == 1 and scl == 1 and prev_sda == 1 and sda == 0:
state = 'START'
current_packet = []
bit_count = 0
current_byte = 0
# Detecta STOP condition
elif prev_scl == 1 and scl == 1 and prev_sda == 0 and sda == 1:
if current_packet:
packets.append({
'type': 'I2C',
'data': current_packet,
'address': current_packet[0] >> 1 if current_packet else None,
'rw': 'READ' if current_packet and (current_packet[0] & 1) else 'WRITE'
})
state = 'IDLE'
# Rising edge de SCL: amostra bit
elif prev_scl == 0 and scl == 1 and state == 'START':
current_byte = (current_byte << 1) | sda
bit_count += 1
if bit_count == 8:
current_packet.append(current_byte)
bit_count = 0
current_byte = 0
elif bit_count == 9:
# ACK/NACK
ack = 'ACK' if sda == 0 else 'NACK'
current_packet.append(ack)
bit_count = 0
current_byte = 0
prev_scl = scl
prev_sda = sda
return packets
SPI: Serial Peripheral Interface
SPI é mais simples: clock contínuo, dados amostrados em uma das bordas (configurável). Usamos 4 linhas: SCLK, MOSI, MISO, CS.
class SPIParser:
def __init__(self, sclk_channel=0, mosi_channel=1, miso_channel=2, cs_channel=3,
cpol=0, cpha=0):
self.sclk = sclk_channel
self.mosi = mosi_channel
self.miso = miso_channel
self.cs = cs_channel
self.cpol = cpol # Clock polarity
self.cpha = cpha # Clock phase
def parse(self, samples):
"""Decodifica transações SPI"""
packets = []
current_mosi = []
current_miso = []
prev_sclk = None
bit_count = 0
mosi_byte = 0
miso_byte = 0
for sample in samples:
sclk = (sample >> self.sclk) & 1
mosi = (sample >> self.mosi) & 1
miso = (sample >> self.miso) & 1
cs = (sample >> self.cs) & 1
# CS ativo em LOW
if cs == 0:
if prev_sclk is not None:
# Detecta borda de clock (depende de CPHA/CPOL)
capture_edge = False
if self.cpha == 0:
capture_edge = (prev_sclk == self.cpol and sclk != self.cpol)
else:
capture_edge = (prev_sclk != self.cpol and sclk == self.cpol)
if capture_edge:
mosi_byte = (mosi_byte << 1) | mosi
miso_byte = (miso_byte << 1) | miso
bit_count += 1
if bit_count == 8:
current_mosi.append(mosi_byte)
current_miso.append(miso_byte)
bit_count = 0
mosi_byte = 0
miso_byte = 0
else:
# CS subiu: fim da transação
if current_mosi:
packets.append({
'type': 'SPI',
'mosi': current_mosi,
'miso': current_miso
})
current_mosi = []
current_miso = []
bit_count = 0
prev_sclk = sclk
return packets
UART: Serial Assíncrono
UART é o mais simples: sem clock, só dados. A sincronização acontece no bit de START (LOW). Depois, amostramos no meio de cada bit.
class UARTParser:
def __init__(self, rx_channel=0, baudrate=9600, sample_rate=1_000_000):
self.rx = rx_channel
self.baudrate = baudrate
self.sample_rate = sample_rate
self.samples_per_bit = sample_rate // baudrate
def parse(self, samples):
"""Decodifica bytes UART"""
packets = []
i = 0
while i < len(samples):
rx = (samples[i] >> self.rx) & 1
# Detecta START bit (HIGH para LOW)
if rx == 0:
# Avança para o meio do primeiro bit de dados
i += self.samples_per_bit // 2
# Lê 8 bits de dados
byte_value = 0
for bit in range(8):
if i + self.samples_per_bit < len(samples):
sample = samples[i + self.samples_per_bit * bit]
rx_bit = (sample >> self.rx) & 1
byte_value |= (rx_bit << bit)
packets.append({
'type': 'UART',
'byte': byte_value,
'char': chr(byte_value) if 32 <= byte_value <= 126 else '?'
})
# Avança para o próximo byte
i += self.samples_per_bit * 10 # 8 data + 1 start + 1 stop
else:
i += 1
return packets
Visualização e Análise no Terminal
Com os parsers prontos, precisamos de uma interface para visualizar os resultados. Nada de GUI complexa — vamos usar o terminal com formatação ANSI.

from colorama import Fore, Style
class TerminalVisualizer:
def __init__(self):
# Inicializa colorama
import colorama
colorama.init()
def display_i2c(self, packets):
"""Exibe pacotes I2C formatados"""
print(f"\n{Fore.CYAN}{'='*60}{Style.RESET_ALL}")
print(f"{Fore.CYAN}I2C PACKETS ({len(packets)} total){Style.RESET_ALL}")
print(f"{Fore.CYAN}{'='*60}{Style.RESET_ALL}\n")
for i, pkt in enumerate(packets, 1):
addr = pkt['address']
rw = pkt['rw']
data = pkt['data']
print(f"{Fore.GREEN}[{i:03d}]{Style.RESET_ALL} ", end='')
print(f"Addr: {Fore.YELLOW}0x{addr:02X}{Style.RESET_ALL} ", end='')
print(f"({rw}) ", end='')
# Exibe bytes de dados
for j, byte in enumerate(data):
if isinstance(byte, int):
print(f"{Fore.WHITE}0x{byte:02X}{Style.RESET_ALL} ", end='')
else:
# ACK/NACK
color = Fore.GREEN if byte == 'ACK' else Fore.RED
print(f"{color}[{byte}]{Style.RESET_ALL} ", end='')
print()
def display_spi(self, packets):
"""Exibe transações SPI"""
print(f"\n{Fore.CYAN}{'='*60}{Style.RESET_ALL}")
print(f"{Fore.CYAN}SPI TRANSACTIONS ({len(packets)} total){Style.RESET_ALL}")
print(f"{Fore.CYAN}{'='*60}{Style.RESET_ALL}\n")
for i, pkt in enumerate(packets, 1):
print(f"{Fore.GREEN}[{i:03d}]{Style.RESET_ALL}")
print(f" {Fore.YELLOW}MOSI:{Style.RESET_ALL} ", end='')
print(' '.join(f'0x{b:02X}' for b in pkt['mosi']))
print(f" {Fore.YELLOW}MISO:{Style.RESET_ALL} ", end='')
print(' '.join(f'0x{b:02X}' for b in pkt['miso']))
print()
def display_uart(self, packets):
"""Exibe bytes UART"""
print(f"\n{Fore.CYAN}{'='*60}{Style.RESET_ALL}")
print(f"{Fore.CYAN}UART DATA ({len(packets)} bytes){Style.RESET_ALL}")
print(f"{Fore.CYAN}{'='*60}{Style.RESET_ALL}\n")
# Exibe como texto contínuo
text = ''.join(pkt['char'] for pkt in packets)
print(f"{Fore.WHITE}{text}{Style.RESET_ALL}\n")
# Exibe hex dump
print(f"{Fore.YELLOW}Hex dump:{Style.RESET_ALL}")
hex_str = ' '.join(f'{pkt["byte"]:02X}' for pkt in packets)
print(f"{Fore.WHITE}{hex_str}{Style.RESET_ALL}\n")
Juntando Tudo: Script Principal
Agora vamos integrar tudo em um script único que captura, analisa e exibe:
#!/usr/bin/env python3
"""
Analisador Lógico USB com ESP32 e Python Puro
Debug de I2C, SPI e UART sem equipamento caro
"""
import argparse
from receiver import LogicAnalyzerReceiver
from parsers import I2CParser, SPIParser, UARTParser
from visualizer import TerminalVisualizer
def main():
parser = argparse.ArgumentParser(description='Analisador Lógico USB')
parser.add_argument('--port', default='/dev/ttyUSB0', help='Porta serial do ESP32')
parser.add_argument('--protocol', choices=['i2c', 'spi', 'uart'], required=True)
parser.add_argument('--duration', type=int, default=5, help='Duração da captura (segundos)')
parser.add_argument('--export', help='Exporta para CSV (nome do arquivo)')
args = parser.parse_args()
# Inicia captura
receiver = LogicAnalyzerReceiver(port=args.port, baudrate=115200)
receiver.start()
print(f"[*] Capturando por {args.duration} segundos...")
import time
time.sleep(args.duration)
receiver.stop()
samples = receiver.get_samples()
# Exporta CSV se solicitado
if args.export:
receiver.export_csv(args.export)
# Analisa protocolo
visualizer = TerminalVisualizer()
if args.protocol == 'i2c':
parser = I2CParser(scl_channel=0, sda_channel=1)
packets = parser.parse(samples)
visualizer.display_i2c(packets)
elif args.protocol == 'spi':
parser = SPIParser(sclk_channel=0, mosi_channel=1, miso_channel=2, cs_channel=3)
packets = parser.parse(samples)
visualizer.display_spi(packets)
elif args.protocol == 'uart':
parser = UARTParser(rx_channel=0, baudrate=9600)
packets = parser.parse(samples)
visualizer.display_uart(packets)
print(f"\n[+] Análise completa. {len(packets)} pacotes decodificados.")
if __name__ == '__main__':
main()
Box Perrengue: O Dia Que o Clock Mentirosos Quase Me Derrubou
Eu estava testando o analisador lógico com um sensor BME280 (temperatura, umidade, pressão) via I2C. O firmware do ESP32 capturava amostras perfeitamente, o parser Python decodificava os pacotes, mas os valores de temperatura estavam completamente errados. Em vez de 24°C, lia 87°C.
Depois de horas revisando código, descobri o problema: o timer do ESP32 não estava rodando exatamente a 1 MHz. Havia um erro de ~2% na frequência do cristal, e isso causava drift acumulado. Em capturas longas (>5 segundos), o parser Python perdia sincronização com as bordas de clock do I2C.
A solução? Calibrar o timer usando um sinal de referência conhecido. Conectei um pino do ESP32 a um gerador de clock de 1 kHz preciso, capturei 1000 ciclos, medi o tempo real, e ajustei o divisor do timer. Depois disso, a precisão subiu para 99,98% — mais que suficiente para I2C e SPI.
Lição aprendida: hardware barato funciona, mas exige calibração. Se você vai usar esse projeto em produção, invista em um ESP32 com cristal de alta precisão (±10 ppm) ou use um osciloscópio externo como referência.
Casos de Uso Reais: Onde Esse Analisador Salva o Dia
Construí esse analisador por frustração, mas ele se tornou indispensável. Aqui estão três casos onde ele me salvou:
1. Debug de Sensor I2C Que Não Respondia
Um MPU6050 (acelerômetro) não respondia a nenhum comando. Com o analisador, vi que o endereço I2C estava correto (0x68), mas o sensor enviava NACK em todos os pacotes. Isso indicava que o sensor não estava alimentado corretamente. Medi a tensão no pino VCC: 2,8V em vez de 3,3V. O regulador estava com defeito. Sem o analisador, eu teria perdido horas testando software quando o problema era hardware.
2. SPI Mode Mismatch
Um display OLED SPI mostrava lixo na tela. O datasheet dizia “SPI Mode 0” (CPOL=0, CPHA=0), mas com o analisador descobri que o display esperava Mode 3 (CPOL=1, CPHA=1). O fabricante errou o datasheet. Sem ver as formas de onda, eu nunca teria suspeitado.
3. UART Baud Rate Drift
Comunicação UART entre dois microcontroladores falhava intermitentemente. Configurei ambos para 9600 baud, mas com o analisador medi a taxa real: um estava a 9615 baud e o outro a 9585 baud. Essa diferença de 0,3% causava erros após ~100 bytes. A solução foi usar cristais de 11,0592 MHz (que geram baud rates exatos) em vez de 16 MHz genéricos.
Próximos Passos e Melhorias
Esse projeto é um ponto de partida. Aqui estão ideias para evoluir:
- Triggering: Adicionar suporte para captura condicional (“só capture quando o pino X for HIGH”)
- Protocolos adicionais: Decodificar CAN, 1-Wire, ou até USB Low-Speed
- GUI web: Interface Flask/FastAPI com gráficos interativos usando Chart.js
- Compressão: RLE (Run-Length Encoding) no firmware para reduzir tráfego USB
- Múltiplos ESP32: Sincronizar dois ou mais para 16+ canais
Se você implementar alguma dessas melhorias, me avisa nos comentários. Adoro ver como a comunidade evolui projetos open source.
Conclusão: Ferramenta Feita em Casa, Resultado Profissional
Construir seu próprio analisador lógico é mais que economia — é empoderamento. Você deixa de ser refém de equipamentos caros e software proprietário. Com R$ 85 e uma tarde de código, você tem uma ferramenta que resolve 80% dos problemas de debug digital que makers e desenvolvedores enfrentam.
Sim, você perde recursos avançados. Não vai conseguir debugar USB High-Speed ou PCIe com isso. Mas para I2C, SPI e UART — os protocolos que aparecem em 90% dos projetos maker — funciona perfeitamente. E o melhor: você entende cada linha de código, cada decisão de design.
Se você curtiu esse projeto, deixa um comentário dizendo qual protocolo você quer ver decodificado a seguir. CAN? 1-Wire? Modbus RTU? Me conta qual automação ou debug você quer automatizar, e eu trago no próximo artigo do Lab da Garra.
E se você já passou perrengue debugando protocolo serial sem equipamento adequado, compartilha a história. Bora aprender juntos.
