🌐 Português · Read in English
Controlador de 8 servos hobby num chip RISC-V de ~R$ 0,50.
Escravo I²C · movimento suave · failsafe · poses sincronizadas · varredura diagnóstica
┌──────────────────────┐
Raspberry Pi ───►│ SDA (PC6) │──► Servo 0 (PD2)
Arduino ───►│ SCL (PC5) │──► Servo 1 (PC4)
ESP32 ───►│ │──► Servo 2 (PC3)
qualquer ───►│ CH32V003 │──► Servo 3 (PA1)
mestre I²C │ RISC-V ~R$ 0,50 │──► Servo 4 (PC1)
│ │──► Servo 5 (PD5)
│ │──► Servo 6 (PD6)
└──────────────────────┘──► Servo 7 (PC7)
O CH32V003 tem dois timers de hardware. A maioria dos projetos de controlador de servo desperdiça os dois para gerar PWM contínuo em apenas 2 canais. Este firmware usa o modo one-pulse (OPM): cada pulso é disparado explicitamente por software a cada 20 ms, permitindo que cada timer sirva 4 canais por evento. Dois timers × 4 canais = 8 servos independentes num chip do tamanho de um grão de arroz.
Este projeto também é uma plataforma de aprendizado. Cada decisão de design está explicada no código-fonte.
| Funcionalidade | Detalhe |
|---|---|
| 8 canais | PWM independente a 50 Hz, faixa de 500–2500 µs |
| Resolução de 16 bits | ≈ 41,7 ns por passo, ~0,1° de precisão angular |
| API em µs | Escreva a largura de pulso em microssegundos — sem contas de clock no mestre |
| Movimento suave | Limite de velocidade por canal; o servo desliza em vez de saltar |
| Poses sincronizadas | Todos os canais chegam ao alvo simultaneamente (coreografia de robôs) |
| Failsafe | Watchdog: vai para o centro se o mestre ficar em silêncio |
| Relaxar por canal | Corta o PWM de qualquer canal (economiza energia, permite movimentação manual) |
| Leitura de status | Consulte "este canal ainda está se movendo?" via I²C |
| Varredura diagnóstica | Um comando executa mín→máx→centro em todos os servos |
| Saída intercalada | TIM1 e TIM2 disparam com 10 ms de defasagem, reduzindo o pico de corrente |
| Boot seguro | Todos os canais vão para 1500 µs antes do primeiro comando I²C |
| Limites rígidos | O firmware limita todos os valores a [SERVO_MIN_US, SERVO_MAX_US] |
git clone --recurse-submodules https://github.com/charlesmmorais/ch32-servocd ch32-servo/src
makeRequer o toolchain do ch32fun.
O alvo padrão é flash (minichlink via WCH-Link).
Pino CH32V003 Canal Pino do conector
─────────────────────────────────────────
PD2 0 1
PC4 1 2
PC3 2 3
PA1 3 4
PC1 4 5
PD5 5 6
PD6 6 7
PC7 7 8
PC6 → I²C SDA
PC5 → I²C SCL
GND → GND (compartilhado com mestre e alimentação dos servos)
Atenção à alimentação: servos consomem muita corrente.
Alimente-os com uma fonte 5 V separada. Compartilhe apenas o GND com o CH32V003.
Arduino (Wire)
#include <Wire.h>
void setup() {
Wire.begin();
Wire.setClock(400000);
// Move canal 0 para o centro, imediato
Wire.beginTransmission(0x09);
Wire.write(0x00); Wire.write(0xDC); Wire.write(0x05); // reg 0, 1500 µs LE
Wire.endTransmission();
// Move canal 0 para 900 µs com velocidade = 20 µs/frame (~1 s)
Wire.beginTransmission(0x09);
Wire.write(0x10); Wire.write(20); Wire.write(0); // reg de velocidade
Wire.endTransmission();
Wire.beginTransmission(0x09);
Wire.write(0x00); Wire.write(0x84); Wire.write(0x03); // 900 µs
Wire.endTransmission();
}MicroPython (Pico)
from machine import I2C, Pin
from docs.examples.micropython_master import MiteServo
i2c = I2C(0, sda=Pin(4), scl=Pin(5), freq=400_000)
s = MiteServo(i2c)
s.failsafe(5) # vai para o centro se ficar 500 ms sem I²C
s.move(0, 1500) # canal 0 → centro, imediato
s.move(1, 900, speed=20) # canal 1 → 900 µs, devagar
s.wait(0x03) # aguarda canais 0 e 1 terminarem
s.sync_move({0:800, 1:1200, 2:1800}, duration_ms=1000) # pose coordenadaExemplos completos em docs/examples/.
O CH32V003 se apresenta como escravo I²C no endereço 0x09 (configurável em config.h).
Expõe um banco plano de 38 bytes. Todos os valores multi-byte são little-endian (byte menos significativo primeiro).
| Endereço | Tam. | L/E | Descrição |
|---|---|---|---|
0x00–0x0F |
2 × 8 | E | Posição alvo, canais 0–7 (µs, 500–2500). Escrever 0x0000 = sem alteração. |
0x10–0x1F |
2 × 8 | E | Limite de velocidade, canais 0–7 (µs por frame de 20 ms). 0 = snap imediato. |
| Endereço | Tam. | L/E | Descrição |
|---|---|---|---|
0x20 |
1 | E | Máscara de habilitação — bit N = 1 habilita o canal N. 0 = relaxado (sem PWM). |
0x21 |
1 | L | Status — bit N = 1 enquanto o canal N está se movendo em direção ao alvo. |
0x22 |
1 | E | Timeout do failsafe — unidades de 100 ms. 0 = desabilitado. |
0x23 |
1 | E | Controle — bit 0 = SWEEP, bit 1 = SYNC (se limpa automaticamente). |
0x24–0x25 |
2 | E | Duração do sync (ms, LE) — usado com CTRL_SYNC. |
Escrita I²C: [ENDEREÇO_ESCRAVO | W] [reg] [val_lo] [val_hi]
Mover canal 3 para 2000 µs (0x07D0):
reg = REG_TARGET(3) = 0x06
val_lo = 0xD0
val_hi = 0x07
→ sequência Wire.write: 0x06, 0xD0, 0x07
Timers convencionais de servo rodam a 50 Hz continuamente. Aqui, cada timer é configurado em modo OPM (one-pulse) + contagem decrescente:
ATRLR = 0xFFFF ──►│ contador conta PARA BAIXO
│
CCR ───►│──────────── saída ALTA enquanto contador > CCR
│
0 ──►│ saída BAIXA, CEN se autolimpa, timer para
Setar TIM_CEN dispara exatamente um pulso. O ISR do SysTick rearma a cada 20 ms.
O timer fica ocioso entre os pulsos — sem jitter de recarga.
Tempo: 0ms 10ms 20ms 30ms 40ms
│ │ │ │ │
TIM1: ████ · ████ · ████ (canais 0–3, 50 Hz)
TIM2: · ████ · ████ · (canais 4–7, 50 Hz)
↑ defasagem de 10 ms
Disparar os dois bancos simultaneamente geraria um pico na alimentação. A defasagem de 10 ms distribui a carga.
Cada canal mantém current_us (o que o timer está gerando) e target_us (o que o mestre pediu). A cada frame, current_us avança em direção a target_us por no máximo speed_us:
// Passo por frame (simplificado)
int32_t delta = target_us - current_us;
if (abs(delta) <= speed_us)
current_us = target_us; // chegou
else
current_us += sinal(delta) * speed_us; // um passo mais pertospeed_us = 0 desabilita o slew (snap imediato).
Quando CTRL_SYNC é escrito, o firmware calcula uma velocidade por canal para que todos percorram suas distâncias no mesmo número de frames:
velocidade[canal] = |alvo[canal] - atual[canal]| / (sync_ms / 20)
Cada canal usa a mesma lógica de slew — sem código especial no ISR.
O callback de escrita I²C atualiza um timestamp. O ISR do SysTick compara com o tick atual. Se a diferença ultrapassar o limiar, servo_failsafe() é chamado e define todos os alvos como SERVO_CENTER_US com speed = 0 (imediato).
Edite src/config.h:
#define I2C_ADDRESS 0x09 // Mude para usar várias placas no mesmo barramento
#define SERVO_MIN_US 500 // Piso rígido — o firmware nunca vai abaixo disso
#define SERVO_MAX_US 2500 // Teto rígido
#define SERVO_CENTER_US 1500 // Posição de boot e posição do failsafeNenhum outro arquivo precisa ser alterado para customizações típicas.
ch32-servo/
├── src/
│ ├── config.h ← todos os parâmetros ajustáveis pelo usuário
│ ├── i2c_regs.h ← mapa de registradores (defines + documentação do protocolo)
│ ├── servo.h/.c ← máquina de estados do servo (sem hardware)
│ ├── main.c ← init de hardware, timers, ISR, sweep e sync
│ ├── funconfig.h ← seleção de chip para o ch32fun
│ └── Makefile
├── docs/
│ └── examples/
│ ├── arduino_master.ino ← driver Arduino (Wire) com demo
│ └── micropython_master.py ← driver MicroPython com demo
├── ch32fun/ ← submódulo (cnlohr/ch32fun)
├── README.md ← este arquivo (Português)
├── README.en.md ← versão em inglês
├── LICENSE
└── .gitmodules
O slew atual é de velocidade constante. Para adicionar aceleração/desaceleração, substitua a lógica de passo em servo.c:servo_step_all() por uma máquina de estados que rampa speed_us no início e reduz perto do alvo. O ISR e o código de hardware não precisam de alteração.
Armazene calibração (mín/máx/trim por canal) na última página de flash usando os helpers de flash do ch32fun. Adicione um registrador REG_SAVE que dispara a gravação. Sobrevive a resets.
Exponha 2 pads de solda na PCB conectados a entradas GPIO. Leia-os no boot e combine com os bits inferiores de I2C_ADDRESS. Permite 4 placas no mesmo barramento (endereços 0x09–0x0C) sem regravação do firmware.
Adicione uma camada de conversão no driver do mestre. O firmware permanece em µs intencionalmente — universalmente compreendido e independente do modelo do servo.
- Leitura fragmentada — ler um registrador de alvo de 16 bits enquanto o mestre I²C está escrevendo o segundo byte pode produzir um glitch de um frame. Aceitável para servos hobby (20 ms se autocorrige). Adicione double-buffering para uso crítico de segurança.
- Sem feedback de posição — o firmware não sabe onde o servo realmente está; ele só sabe qual pulso enviou. Sensoriamento externo de posição requer hardware adicional.
- 38 bytes de espaço de registradores — o driver I²C slave do ch32fun usa um buffer de tamanho fixo. Estender o mapa de registradores é simples, mas requer ajustar
I2C_REG_COUNT.
# Ubuntu / Debian
sudo apt install gcc-riscv64-unknown-elf make
git clone https://github.com/cnlohr/minichlink
cd minichlink && make
sudo cp minichlink /usr/local/bin/
# Neste repositório:
git submodule update --init
cd src && makeTestado com GCC 12 (riscv64-unknown-elf).
ch32-servo é uma reescrita completa do deshipu/mite-servo de @deshipu, a quem vai todo o crédito pela ideia central.
O mite-servo introduziu um insight elegante: em vez de rodar PWM contínuo nos dois timers (o que daria apenas 2 canais), configurá-los em modo one-pulse e dispará-los explicitamente de um ISR do SysTick. Isso libera 4 canais por timer — 8 no total, em um chip sem controlador de servo nativo. A defasagem de 10 ms entre TIM1 e TIM2 para distribuir o pico de corrente também foi uma escolha deliberada e inteligente.
Essa arquitetura central está preservada aqui sem alterações. Se o original não existisse, este projeto também não existiria.
O original é limpo e mínimo por design — uma prova de conceito funcional com ~200 linhas de C. Esta reescrita mantém o mesmo tamanho (dentro de 2 KB de RAM / 16 KB de flash) enquanto adiciona tudo que um deploy real precisa:
| mite-servo | ch32-servo | |
|---|---|---|
| Unidade da API | ticks brutos do timer | microssegundos (portável) |
| Movimento suave | snap imediato | slew rate por canal |
| Poses sincronizadas | — | todos os canais chegam juntos |
| Failsafe | — | watchdog → centro no silêncio I²C |
| Relaxar canais | — | desabilita PWM por canal |
| Leitura de status | — | "este canal ainda está se movendo?" |
| Varredura diagnóstica | — | mín → máx → centro por comando |
| Boot seguro | CCR = 0 (pulso indefinido) | todos os canais iniciam em 1500 µs |
| Limitação de valores | nenhuma | mín/máx rígidos no firmware |
| Mapeamento de pinos | implícito (ordem de registradores = mistério) | tabela conector→timer→pino documentada |
| Estrutura do código | arquivo único | lógica de servo separada do hardware |
| Exemplos para mestre | — | Arduino + MicroPython com driver completo |
| Documentação | README resumido | docs de protocolo, cálculos de timing, racional de design |
- Alvo CH32V003 (sem razão para trocar um chip de R$ 0,50 que faz o trabalho)
- Framework ch32fun e exemplo i2c_slave como base I²C
- Configuração de timer OPM + contagem decrescente
- Full remap do TIM2 (mesmos pinos: PC1, PC7, PD6, PD5)
- Pinos I²C PC5/PC6 com high-bit remap
- Endereço I²C padrão 0x09
- Licença MIT
Crédito também a @cnlohr pelo ch32fun, sem o qual nada disso compilaria em menos de 30 segundos.
MIT — veja LICENSE. Faça o que quiser; uma menção é bem-vinda.