Motore DC con ESP32-C6: ponte H, PWM MCPWM e controllo PI
Il motore DC a spazzole è il più semplice e diffuso degli attuatori: due fili, gli dai tensione e gira. Controllarlo bene, però, richiede qualche attenzione: un ponte H per invertire il verso, un PWM a frequenza giusta per regolare la velocità, la scelta tra frenare e lasciarlo andare in folle, una partenza morbida e, se vuoi una velocità costante sotto carico, un encoder con un anello di regolazione. In questo articolo facciamo tutto questo con un ESP32-C6, la periferica MCPWM di ESP-IDF 5.4 e il PCNT per leggere l’encoder.
Cosa serve
| Componente | Esempio | Note |
|---|---|---|
| Scheda di sviluppo | ESP32-C6-DevKitC-1 | Logica a 3,3 V |
| Ponte H | Modulo con DRV8871 (un motore, due ingressi IN1/IN2) | In alternativa TB6612FNG (due motori, ingressi IN1/IN2/PWM/STBY): cambia la logica di pilotaggio, vedi più avanti |
| Motore DC | Motoriduttore a spazzole 12 V con encoder Hall in quadratura | Impulsi per giro e rapporto di riduzione dal datasheet del motore |
| Alimentatore motore | 12 V DC, corrente adeguata allo spunto del motore | Separato dall’alimentazione della logica |
| Condensatori | Elettrolitico 100-470 µF sul VM del driver, ceramico 100 nF sui morsetti del motore | Riducono picchi e disturbi |
| Resistenza limite corrente | Sul pin ILIM del DRV8871 | Valore calcolato con la formula del datasheet (molti moduli la montano già) |
Come funziona
Un ponte H è formato da quattro interruttori (MOSFET) disposti ad H con il motore al centro: chiudendo una diagonale la corrente scorre in un verso, chiudendo l’altra scorre al contrario. I chip integrati come il DRV8871 gestiscono internamente il tempo morto tra gli interruttori dello stesso ramo, la protezione termica e il limite di corrente; a te restano due ingressi. La tabella di verità del DRV8871 è questa: IN1=1, IN2=0 avanti; IN1=0, IN2=1 indietro; entrambi a 1 frenata (i due MOSFET bassi cortocircuitano il motore); entrambi a 0 ruota libera (uscite in alta impedenza).
Frenata o ruota libera? Un motore che gira è anche un generatore. In frenata la sua forza controelettromotrice fa circolare corrente nell’avvolgimento cortocircuitato e questa corrente si oppone al moto: il motore si ferma in fretta. In ruota libera il circuito è aperto e il motore rallenta solo per attrito. La frenata serve per fermate precise, la ruota libera per non stressare la meccanica.
La velocità si regola con il PWM: alimentando il motore per una frazione del periodo (duty cycle) la tensione media scende. L’induttanza dell’avvolgimento livella la corrente se la frequenza è abbastanza alta; sopra i 20 kHz il fischio sparisce perché esce dalla banda udibile. Il massimo ammesso dipende dal driver: verificalo nel datasheet. Nel nostro schema, durante la parte “spenta” del periodo il ponte va in ruota libera (decadimento veloce). L’alternativa è alternare marcia e frenata (IN1 fisso a 1, PWM invertito su IN2, decadimento lento), che dà una relazione tra duty e velocità più lineare: il datasheet del DRV8871 descrive entrambe.
Il soft start limita la velocità di variazione del duty (qui 50% al secondo): evita lo spunto di corrente, gli strappi alla meccanica e i cali di tensione sull’alimentazione.
Per una velocità costante indipendente dal carico serve una misura. Un encoder in quadratura sull’albero motore, letto x4 dal PCNT, dà i conteggi; ogni 10 ms calcoliamo la differenza e la convertiamo in giri al minuto. L’anello PI (proporzionale-integrale) confronta la velocità misurata con il setpoint: il termine proporzionale Kp × errore reagisce subito, il termine integrale accumula l’errore nel tempo ed elimina lo scarto residuo che il solo proporzionale lascerebbe sotto carico. L’integrale viene limitato (anti-windup) per non “caricarsi” troppo quando l’uscita è già al massimo.
Collegamenti
| Segnale | GPIO ESP32-C6 | Note |
|---|---|---|
| IN1 (DRV8871) | GPIO 4 | Uscita MCPWM, generatore A |
| IN2 (DRV8871) | GPIO 5 | Uscita MCPWM, generatore B |
| Encoder canale A | GPIO 6 | Ingresso PCNT, livello massimo 3,3 V |
| Encoder canale B | GPIO 7 | Ingresso PCNT, livello massimo 3,3 V |
| Alimentazione encoder | 3,3 V | Se il tuo encoder richiede 5 V e ha uscite push-pull o pull-up a 5 V, serve un adattatore di livello |
| VM del driver | +12 V alimentatore motore | Con elettrolitico vicino al modulo |
| Masse | GND comune | Unisci le masse in un punto vicino al driver, non far passare la corrente del motore nella massa della scheda |
Una nota sui diodi di ricircolo (flyback): con un ponte H a MOSFET non servono diodi esterni, perché i diodi intrinseci dei MOSFET e la logica del driver gestiscono la corrente induttiva. Servono invece il condensatore di bulk sull’alimentazione, che assorbe l’energia restituita in frenata e i picchi di commutazione, e il condensatore ceramico sui morsetti del motore, che riduce i disturbi generati dalle spazzole. Se il motore frena spesso da alta velocità, controlla che la tensione di VM non salga oltre il limite del driver: il motore in frenata rimanda energia verso l’alimentatore.
La periferica dell’ESP32-C6
L’ESP32-C6 ha un gruppo MCPWM con 3 timer, 3 operatori, 2 comparatori e 2 generatori per operatore. Il modello del driver driver/mcpwm_prelude.h è a oggetti collegati: il timer conta e definisce il periodo (500 tick a 10 MHz = 20 kHz); l’operatore si collega al timer; il comparatore contiene la soglia che fissa il duty; i generatori decidono cosa fare sul pin agli eventi (alto a inizio periodo, basso quando il conteggio raggiunge il comparatore). Con un solo operatore pilotiamo entrambi gli ingressi del ponte: in ogni istante un generatore produce il PWM e l’altro è forzato a un livello fisso con mcpwm_generator_set_force_level(). Il comparatore si aggiorna solo quando il timer torna a zero (update_cmp_on_tez), così non nascono impulsi spuri a metà periodo.
Perché MCPWM e non LEDC? Anche LEDC genera PWM, ma MCPWM è pensato per il controllo motori: forzature sincrone, generatori coordinati, tempo morto, ingressi di fault per fermare il ponte in hardware. Qui usiamo il minimo indispensabile, ma la struttura è pronta a crescere. L’encoder usa invece il PCNT (driver/pulse_cnt.h) con due canali incrociati, come nell’articolo sulla manopola encoder: decodifica x4 senza carico sulla CPU.
Il codice
Progetto verificato con idf.py build su ESP-IDF 5.4, target esp32c6. Ecco main/main.c:
/*
* Motore DC a spazzole con ponte H (DRV8871 o simili) su ESP32-C6 (ESP-IDF 5.4)
* - PWM a 20 kHz con MCPWM: timer, operatore, comparatore, due generatori
* - direzione, frenata (brake) e ruota libera (coast), soft start
* - retroazione di velocita' da encoder in quadratura (PCNT) con anello PI
* www.webappportal.com
*/
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/mcpwm_prelude.h"
#include "driver/pulse_cnt.h"
#include "driver/gpio.h"
#include "esp_log.h"
static const char *TAG = "motore_dc";
#define GPIO_IN1 4
#define GPIO_IN2 5
#define GPIO_ENC_A 6
#define GPIO_ENC_B 7
#define PWM_RES_HZ 10000000 /* 10 MHz, 1 tick = 100 ns */
#define PWM_FREQ_HZ 20000 /* sopra la soglia udibile */
#define PWM_PERIOD_TICKS (PWM_RES_HZ / PWM_FREQ_HZ) /* 500 tick */
/* Esempio: encoder da 11 impulsi/giro, lettura x4, riduttore 30:1.
* Sostituisci con i dati del TUO motoriduttore. */
#define ENC_CPR (11 * 4 * 30)
#define CTRL_PERIOD_MS 10
#define DUTY_SLEW 0.5f /* soft start: max variazione di duty al secondo (0.5 = 50%/s) */
#define RPM_SLEW 200.0f /* rampa del setpoint in anello chiuso, rpm/s */
#define KP 0.004f /* duty per rpm di errore: da tarare sul tuo motore */
#define KI 0.03f /* duty per (rpm * s) di errore integrato */
typedef enum { MODE_COAST, MODE_BRAKE, MODE_OPEN_LOOP, MODE_SPEED } ctrl_mode_t;
static mcpwm_cmpr_handle_t s_cmp;
static mcpwm_gen_handle_t s_gen_in1, s_gen_in2;
static volatile ctrl_mode_t s_mode = MODE_COAST;
static volatile float s_target_duty; /* -1..+1 in anello aperto */
static volatile float s_target_rpm; /* setpoint in anello chiuso */
static float clampf(float v, float lo, float hi)
{
return v < lo ? lo : (v > hi ? hi : v);
}
static float slew(float cur, float target, float max_step)
{
return cur + clampf(target - cur, -max_step, max_step);
}
static void pwm_init(void)
{
mcpwm_timer_handle_t timer = NULL;
mcpwm_timer_config_t timer_cfg = {
.group_id = 0,
.clk_src = MCPWM_TIMER_CLK_SRC_DEFAULT,
.resolution_hz = PWM_RES_HZ,
.period_ticks = PWM_PERIOD_TICKS,
.count_mode = MCPWM_TIMER_COUNT_MODE_UP,
};
ESP_ERROR_CHECK(mcpwm_new_timer(&timer_cfg, &timer));
mcpwm_oper_handle_t oper = NULL;
mcpwm_operator_config_t oper_cfg = { .group_id = 0 };
ESP_ERROR_CHECK(mcpwm_new_operator(&oper_cfg, &oper));
ESP_ERROR_CHECK(mcpwm_operator_connect_timer(oper, timer));
mcpwm_comparator_config_t cmp_cfg = { .flags.update_cmp_on_tez = true };
ESP_ERROR_CHECK(mcpwm_new_comparator(oper, &cmp_cfg, &s_cmp));
ESP_ERROR_CHECK(mcpwm_comparator_set_compare_value(s_cmp, 0));
mcpwm_generator_config_t gen_cfg = { .gen_gpio_num = GPIO_IN1 };
ESP_ERROR_CHECK(mcpwm_new_generator(oper, &gen_cfg, &s_gen_in1));
gen_cfg.gen_gpio_num = GPIO_IN2;
ESP_ERROR_CHECK(mcpwm_new_generator(oper, &gen_cfg, &s_gen_in2));
/* Entrambi i generatori: ALTO a inizio periodo, BASSO al confronto.
* In ogni istante solo uno dei due e' libero, l'altro e' forzato. */
mcpwm_gen_handle_t gens[2] = { s_gen_in1, s_gen_in2 };
for (int i = 0; i < 2; i++) {
ESP_ERROR_CHECK(mcpwm_generator_set_action_on_timer_event(gens[i],
MCPWM_GEN_TIMER_EVENT_ACTION(MCPWM_TIMER_DIRECTION_UP, MCPWM_TIMER_EVENT_EMPTY, MCPWM_GEN_ACTION_HIGH)));
ESP_ERROR_CHECK(mcpwm_generator_set_action_on_compare_event(gens[i],
MCPWM_GEN_COMPARE_EVENT_ACTION(MCPWM_TIMER_DIRECTION_UP, s_cmp, MCPWM_GEN_ACTION_LOW)));
ESP_ERROR_CHECK(mcpwm_generator_set_force_level(gens[i], 0, true)); /* partenza: coast */
}
ESP_ERROR_CHECK(mcpwm_timer_enable(timer));
ESP_ERROR_CHECK(mcpwm_timer_start_stop(timer, MCPWM_TIMER_START_NO_STOP));
}
/* DRV8871: IN1=PWM IN2=0 avanti, IN1=0 IN2=PWM indietro (decadimento veloce) */
static void motor_drive(float duty)
{
uint32_t ticks = (uint32_t)(clampf(duty < 0 ? -duty : duty, 0.0f, 1.0f) * PWM_PERIOD_TICKS);
ESP_ERROR_CHECK(mcpwm_comparator_set_compare_value(s_cmp, ticks));
if (duty > 0) {
ESP_ERROR_CHECK(mcpwm_generator_set_force_level(s_gen_in2, 0, true));
ESP_ERROR_CHECK(mcpwm_generator_set_force_level(s_gen_in1, -1, true));
} else if (duty < 0) {
ESP_ERROR_CHECK(mcpwm_generator_set_force_level(s_gen_in1, 0, true));
ESP_ERROR_CHECK(mcpwm_generator_set_force_level(s_gen_in2, -1, true));
} else {
ESP_ERROR_CHECK(mcpwm_generator_set_force_level(s_gen_in1, 0, true));
ESP_ERROR_CHECK(mcpwm_generator_set_force_level(s_gen_in2, 0, true));
}
}
static void motor_coast(void) /* IN1=0 IN2=0: uscite in alta impedenza */
{
ESP_ERROR_CHECK(mcpwm_generator_set_force_level(s_gen_in1, 0, true));
ESP_ERROR_CHECK(mcpwm_generator_set_force_level(s_gen_in2, 0, true));
}
static void motor_brake(void) /* IN1=1 IN2=1: avvolgimento cortocircuitato */
{
ESP_ERROR_CHECK(mcpwm_generator_set_force_level(s_gen_in1, 1, true));
ESP_ERROR_CHECK(mcpwm_generator_set_force_level(s_gen_in2, 1, true));
}
static pcnt_unit_handle_t encoder_init(void)
{
pcnt_unit_config_t unit_cfg = {
.low_limit = -30000,
.high_limit = 30000,
.flags.accum_count = true,
};
pcnt_unit_handle_t unit = NULL;
ESP_ERROR_CHECK(pcnt_new_unit(&unit_cfg, &unit));
pcnt_glitch_filter_config_t filter_cfg = { .max_glitch_ns = 1000 };
ESP_ERROR_CHECK(pcnt_unit_set_glitch_filter(unit, &filter_cfg));
pcnt_chan_config_t a_cfg = { .edge_gpio_num = GPIO_ENC_A, .level_gpio_num = GPIO_ENC_B };
pcnt_chan_config_t b_cfg = { .edge_gpio_num = GPIO_ENC_B, .level_gpio_num = GPIO_ENC_A };
pcnt_channel_handle_t cha = NULL, chb = NULL;
ESP_ERROR_CHECK(pcnt_new_channel(unit, &a_cfg, &cha));
ESP_ERROR_CHECK(pcnt_new_channel(unit, &b_cfg, &chb));
ESP_ERROR_CHECK(pcnt_channel_set_edge_action(cha, PCNT_CHANNEL_EDGE_ACTION_DECREASE, PCNT_CHANNEL_EDGE_ACTION_INCREASE));
ESP_ERROR_CHECK(pcnt_channel_set_level_action(cha, PCNT_CHANNEL_LEVEL_ACTION_KEEP, PCNT_CHANNEL_LEVEL_ACTION_INVERSE));
ESP_ERROR_CHECK(pcnt_channel_set_edge_action(chb, PCNT_CHANNEL_EDGE_ACTION_INCREASE, PCNT_CHANNEL_EDGE_ACTION_DECREASE));
ESP_ERROR_CHECK(pcnt_channel_set_level_action(chb, PCNT_CHANNEL_LEVEL_ACTION_KEEP, PCNT_CHANNEL_LEVEL_ACTION_INVERSE));
ESP_ERROR_CHECK(pcnt_unit_add_watch_point(unit, 30000));
ESP_ERROR_CHECK(pcnt_unit_add_watch_point(unit, -30000));
ESP_ERROR_CHECK(pcnt_unit_enable(unit));
ESP_ERROR_CHECK(pcnt_unit_clear_count(unit));
ESP_ERROR_CHECK(pcnt_unit_start(unit));
return unit;
}
static void control_task(void *arg)
{
pcnt_unit_handle_t enc = (pcnt_unit_handle_t)arg;
const float dt = CTRL_PERIOD_MS / 1000.0f;
TickType_t last_wake = xTaskGetTickCount();
int last_count = 0;
float duty = 0.0f, integ = 0.0f, rpm_sp = 0.0f;
int log_div = 0;
while (1) {
vTaskDelayUntil(&last_wake, pdMS_TO_TICKS(CTRL_PERIOD_MS));
int count = 0;
ESP_ERROR_CHECK(pcnt_unit_get_count(enc, &count));
float rpm = (float)(count - last_count) * 60.0f / ((float)ENC_CPR * dt);
last_count = count;
switch (s_mode) {
case MODE_COAST:
duty = 0.0f;
motor_coast();
break;
case MODE_BRAKE:
duty = 0.0f;
motor_brake();
break;
case MODE_OPEN_LOOP:
duty = slew(duty, s_target_duty, DUTY_SLEW * dt); /* soft start/stop */
motor_drive(duty);
break;
case MODE_SPEED: {
rpm_sp = slew(rpm_sp, s_target_rpm, RPM_SLEW * dt);
float err = rpm_sp - rpm;
integ = clampf(integ + KI * err * dt, -1.0f, 1.0f); /* anti-windup */
duty = clampf(KP * err + integ, -1.0f, 1.0f);
motor_drive(duty);
break;
}
}
if (s_mode != MODE_SPEED) {
integ = duty; /* ripartenza morbida quando si passa all'anello chiuso */
rpm_sp = rpm;
}
if (++log_div >= 20) { /* log ogni 200 ms */
log_div = 0;
ESP_LOGI(TAG, "modo=%d duty=%+.2f rpm=%+6.1f sp=%+6.1f",
(int)s_mode, duty, rpm, rpm_sp);
}
}
}
void app_main(void)
{
pwm_init();
pcnt_unit_handle_t enc = encoder_init();
xTaskCreate(control_task, "ctrl", 4096, enc, 10, NULL);
while (1) {
ESP_LOGI(TAG, "1) Anello aperto: soft start avanti al 60%%");
s_target_duty = 0.6f;
s_mode = MODE_OPEN_LOOP;
vTaskDelay(pdMS_TO_TICKS(4000));
ESP_LOGI(TAG, "2) Ruota libera (coast)");
s_mode = MODE_COAST;
vTaskDelay(pdMS_TO_TICKS(3000));
ESP_LOGI(TAG, "3) Indietro al 60%% e poi frenata (brake)");
s_target_duty = -0.6f;
s_mode = MODE_OPEN_LOOP;
vTaskDelay(pdMS_TO_TICKS(4000));
s_mode = MODE_BRAKE;
vTaskDelay(pdMS_TO_TICKS(2000));
ESP_LOGI(TAG, "4) Anello chiuso PI: 100 rpm, poi 200 rpm, poi -150 rpm");
s_target_rpm = 100.0f;
s_mode = MODE_SPEED;
vTaskDelay(pdMS_TO_TICKS(4000));
s_target_rpm = 200.0f;
vTaskDelay(pdMS_TO_TICKS(4000));
s_target_rpm = -150.0f;
vTaskDelay(pdMS_TO_TICKS(5000));
s_target_rpm = 0.0f;
vTaskDelay(pdMS_TO_TICKS(2000));
s_mode = MODE_COAST;
vTaskDelay(pdMS_TO_TICKS(3000));
}
}
I punti chiave:
pwm_init(): crea timer, operatore, comparatore e i due generatori. Le azioni sono le stesse per entrambi i generatori; all’avvio sono forzati a 0 (ruota libera).motor_drive(): imposta il comparatore in base al modulo del duty, poi “libera” il generatore del verso richiesto (-1rimuove la forzatura) e forza a 0 l’altro.motor_coast()emotor_brake()forzano entrambi a 0 o a 1.control_task(): gira ogni 10 ms convTaskDelayUntil(), che garantisce un periodo regolare. Legge il PCNT e calcola gli rpm. In anello aperto applica la rampa al duty (soft start); in anello chiuso applica la rampa al setpoint e calcola il PI. Quando non è in anello chiuso, inizializza l’integrale con il duty corrente: il passaggio all’anello chiuso non provoca scossoni.ENC_CPR: conteggi per giro dell’albero in uscita. L’esempio (11 impulsi × 4 × riduzione 30) è solo indicativo: usa i dati del tuo motoriduttore.app_main(): esegue una sequenza dimostrativa che copre tutte le modalità: soft start, ruota libera, inversione, frenata, anello chiuso con tre setpoint.
Taratura del PI: parti con KI = 0 e aumenta KP finché la velocità risponde prontamente senza oscillare; poi aumenta KI finché lo scarto a regime sparisce in un tempo ragionevole. I valori nel codice sono un punto di partenza, non valori validi per ogni motore. Prima di chiudere l’anello verifica in anello aperto che un duty positivo produca rpm positivi: se il segno è invertito, il PI diventa una retroazione positiva e il motore va al massimo. Si corregge scambiando A e B dell’encoder.
Prova e risultati attesi
Con un motoriduttore da 12 V e i valori di esempio, il log mostra la rampa del soft start, la decelerazione in ruota libera e l’inseguimento del setpoint (modo 0 = coast, 1 = brake, 2 = anello aperto, 3 = anello chiuso):
I (318) motore_dc: 1) Anello aperto: soft start avanti al 60% I (528) motore_dc: modo=2 duty=+0.10 rpm= +13.6 sp= +13.6 I (728) motore_dc: modo=2 duty=+0.20 rpm= +45.5 sp= +45.5 I (928) motore_dc: modo=2 duty=+0.30 rpm= +81.8 sp= +81.8 I (1128) motore_dc: modo=2 duty=+0.40 rpm=+113.6 sp=+113.6 I (1728) motore_dc: modo=2 duty=+0.60 rpm=+186.4 sp=+186.4 I (4318) motore_dc: 2) Ruota libera (coast) I (4528) motore_dc: modo=0 duty=+0.00 rpm= +95.5 sp= +95.5 I (4728) motore_dc: modo=0 duty=+0.00 rpm= +40.9 sp= +40.9 ... I (14318) motore_dc: 4) Anello chiuso PI: 100 rpm, poi 200 rpm, poi -150 rpm I (15128) motore_dc: modo=3 duty=+0.33 rpm= +95.5 sp=+100.0 I (15328) motore_dc: modo=3 duty=+0.33 rpm=+100.0 sp=+100.0 I (18528) motore_dc: modo=3 duty=+0.64 rpm=+195.5 sp=+200.0 I (18728) motore_dc: modo=3 duty=+0.65 rpm=+200.0 sp=+200.0
Nota che gli rpm sono sempre multipli di circa 4,5: con 1320 conteggi per giro e una finestra di 10 ms, un conteggio vale 60 / (1320 × 0,01) ≈ 4,5 rpm. È la risoluzione della misura. Se ti serve più precisione a bassa velocità, allarga la finestra di misura, filtra la velocità o misura il tempo tra i fronti invece di contarli.
Problemi comuni
- L’ESP32 si riavvia quando il motore parte: calo di tensione o disturbi sulla massa. Alimentazioni separate, massa unita in un solo punto, condensatori vicini al driver e sul motore.
- Il motore fischia: la frequenza PWM è nella banda udibile; con 20 kHz il problema non dovrebbe esserci.
- Sotto un certo duty il motore non parte: normale, serve una coppia minima per vincere l’attrito statico. In anello chiuso ci pensa l’integrale; in anello aperto puoi aggiungere un offset minimo.
- In anello chiuso va subito al massimo: segno dell’encoder invertito rispetto al verso del motore (vedi sopra) o
ENC_CPRmolto sbagliato. - Oscillazioni di velocità:
KPoKItroppo alti. Dimezzali e riprova. - Con il TB6612FNG non funziona: quel driver vuole la direzione su IN1/IN2, il PWM sul pin PWM e STBY alto. Devi adattare
motor_drive(): IN1/IN2 come GPIO normali e un solo generatore MCPWM sul pin PWM.
Va oltre
Con due motori e due encoder hai la base di un piccolo robot a guida differenziale: l’ESP32-C6 ha abbastanza operatori MCPWM e unità PCNT per entrambi. Puoi aggiungere un ingresso di fault MCPWM collegato a un pulsante di emergenza per fermare il ponte in hardware, o misurare la corrente del motore per riconoscere un blocco meccanico. In domotica un motoriduttore con encoder muove un cancelletto, una finestra a vasistas o una telecamera orientabile per l’antifurto. Altri progetti con la famiglia ESP32 sono nella sezione ESP32.
