Table des matières

Arduino GIGA R1 : modulation de largeur d'impulsion

1. Introduction

Ce document montre comment générer un signal à modulation de largeur d'impulsion (MLI) sur un Arduino GIGA R1. Nous verrons tout d'abord comment utiliser l'interface de programmation MBED, avant de décrire en profondeur l'utilisation des Timers du microcontrôleur STM32H747XI pour la génération de signaux PWM (Pulse Width Modulation).

Les signaux MLI sont principalement utilisés pour piloter les ponts de transistors. Nous verrons donc en application quelques exemples de pilotage.

Pour le pilotage d'un seul transistor (alimentation d'une charge avec un courant continu toujours dans le même sens), la fonction analogWrite de l'API Arduino peut convenir mais la fréquence du signal rectangulaire généré est fixe (500 Hz).

Notons un point de vocabulaire : PWM (ou MLI en français) désigne à proprement parler un signal rectangulaire dont le rapport cyclique est modulé. Cependant, par commodité, on désigne également par PWM le signal lorsque son rapport cyclique est fixe.

2. Utilisation de l'API MBED

L'arduino GIGA R1 fait tourner le système d'exploitation MBED OS. L'API (Application Programming Interface) MBED permet d'accéder aux périphériques, comme l'API Arduino mais de manière plus complète. L'utilisation de MBED a un double avantage : il offre la possibilité de faire de la programmation multi-tâche et le code C++ peut être compilé (avec quelques adaptations mineures) pour tous les systèmes embarqués compatibles MBED.

L'API MBED permet de programmer les générateurs PWM via la classe PwmOut. Les fonctions de cette classe permettent de choisir la période du signal et le rapport cyclique. Pour effectuer la modulation, il faut modifier le rapport cyclique à intervalle de temps régulier. Nous utilisons pour cela la classe Ticker, qui permet d'exécuter une fonction périodiquement, à intervalle de temps régulier.

mbed_pwm.ino
#include "Arduino.h"
#include "mbed.h"

#define NSAMPLES 100
#define OUT_1 2
#define OUT_2 3

float table_1[NSAMPLES];
float table_2[NSAMPLES];

mbed::PwmOut pwm_1(digitalPinToPinName(OUT_1));
mbed::PwmOut pwm_2(digitalPinToPinName(OUT_2));
mbed::Ticker tick;
uint16_t indice;

void update() {
  pwm_1.write(table_1[indice]);
  pwm_2.write(table_2[indice]);
  indice += 1;
  if (indice==NSAMPLES) indice=0;
}

void setup() {
  for (indice=0; indice<NSAMPLES; indice++) {
    table_1[indice] = 0.5+0.4*sin(2*PI*indice/NSAMPLES);
    table_2[indice] = 0.5+0.4*cos(2*PI*indice/NSAMPLES);
  }
  pwm_1.period_us(100);
  pwm_1.write(0.5);
  pwm_2.period_us(100);
  pwm_2.write(0.5);
  indice = 0.0;
  tick.attach_us(&update,1000);
}

void loop() {
  

}     
    	     

Ce programme génère deux signaux PWM (sur les sorties D2 et D3) de fréquence 10kHz, modulés chacun par une sinusoïde de fréquence 10Hz. Les rapports cycliques correspondants à ces deux modulations sont stockés dans deux tables contenant 100 échantillons. Après avoir lancé les deux PWM avec une période de 100 microsecondes, on attache la fonction update au Ticker. Cette fonction, exécutée toutes les millisecondes, effectue la mise à jour des deux rapports cycliques en utilisant les valeurs stockées dans les tables.

Pour tester ce programme, on place un filtre RC passe-bas de fréquence de coupure 106 Hz (1500Ω et 1000nF) en sortie de D2 et D3. Voici les signaux en sortie de ces deux filtres :

import numpy as np
from matplotlib.pyplot import *

[t,u1,u2] = np.loadtxt("pwmout-100us-modulation.txt",unpack=True,skiprows=1)
figure(figsize=(16,6))
plot(t*1e3,u1,label="D2 filtrée")
plot(t*1e3,u2,label="D3 filtrée")
grid()
legend(loc="upper right")
ylabel("Volts")
ylim(0,3.3)
xlabel("t (ms)",fontsize=16)
                 
fig1fig1.pdf

Une période de PWM de 10 microsecondes et un changement du rapport cyclique toutes les 100 microsecondes sont obtenus avec la fonction setup suivante :

void setup() {
  for (indice=0; indice<NSAMPLES; indice++) {
    table_1[indice] = 0.5+0.4*sin(2*PI*indice/NSAMPLES);
    table_2[indice] = 0.5+0.4*cos(2*PI*indice/NSAMPLES);
  }
  pwm_1.period_us(10);
  pwm_1.write(0.5);
  pwm_2.period_us(10);
  pwm_2.write(0.5);
  indice = 0.0;
  tick.attach_us(&update,100);
}
                 

Voici le résultat (la fréquence est 10 fois plus grande) :

[t,u1,u2] = np.loadtxt("pwmout-10us-modulation.txt",unpack=True,skiprows=1)
figure(figsize=(16,6))
plot(t*1e3,u1,label="D2 filtrée")
plot(t*1e3,u2,label="D3 filtrée")
grid()
legend(loc="upper right")
ylabel("Volts")
ylim(0,3.3)
xlabel("t (ms)",fontsize=16)
                 
fig2fig2.pdf

La modulation fonctionne parfaitement. L'amplitude est un peu réduite car la fréquence de modulation (100 Hz) est proche de la fréquence de coupure du filtre.

L'utilisation de PwmOut et de Ticker permet donc de générer des signaux de fréquence choisie et de faire de la modulation par une forme d'onde. Cependant, la fonction PwmOut ne permet pas d'exploiter toutes les possibilités des timers qui génèrent les signaux PWM. La principale limitation est l'impossibilité (à notre connaissance) de générer avec le même timer deux signaux complémentaires, une fonction très utile pour piloter les deux transistors d'un bras de pont. La commande des deux transistors est possible avec un seul signal si l'on emploie un circuit de pilotage à une seule entrée (par exemple l'IR2184) mais les deux transistors sont alors toujours en opposition. Dans la commutation à 6 états (voir plus loin), les deux transistors d'un bras sont soit en opposition soit tous les deux ouverts. En conséquence, ces deux transistors doivent être commandés par deux sorties différentes de l'Arduino et par le même timer.

3. Programmation des timers

3.a. Principe

Il s'agit de programmer les timers du microcontrôleur en accédant directement aux registres. Trois documents sont utiles :

Le doc 1 indique à quel port GPIO du microcontrôleur chaque borne de la carte Arduino est reliée. Par exemple, il nous informe que la borne D2 est reliée au port PA3. Chaque port est identifié par un groupe défini par une lettre et par un numéro dans ce groupe. Un timer peut générer des signaux PWM sur des ports bien précis. Pour qu'un port soit utilisé comme sortie d'un timer, il doit être configuré en mode AF (Alternate Function). Le tableau 8 du doc 2 indique, pour chaque port du groupe A, les fonctions alternatives disponibles. Celles correspondant à des sorties de timers sont notées TIMn-CHx où n désigne le numéro du timer et x le numéro de la sortie du timer. Les tableaux suivants font de même pour les autres groupes de port.

Le tableau suivant regroupe les informations importantes : pour chaque borne de la carte, le port correspondant et les sorties de timers disponibles. Chaque timer est identifié par une couleur.

timers

Les timers 1 et 8 sont des timers avancés 16 bits. Chacun comporte 4 sorties (CH1, CH2, CH3, CH4) et à chaque sortie est associée une sortie qui délivre le signal complémentaire (CH1N, CH2N, CH3N, CH4N). Remarquons que toutes les sorties d'un timer ne sont pas nécessairement disponibles sur la carte Arduino. Une sortie et son complémentaire permet de piloter les deux transistors d'un bras de pont (transistor bas et transistor haut). Cette fonction est intéressante mais pas indispensable car il est aisé de programmer deux signaux complémentaires sur deux sorties différentes (par exemple CH1 et CH2) d'un même timer. Le timer 1, ou le timer 8, peut piloter les 6 transistors du pont d'un onduleur triphasé, car dans ce cas les deux transistors d'un bras sont toujours en opposition (mais un circuit de pilotage à une seule entrée est un meilleur choix dans ce cas).

Pour piloter un pont de 6 transistors afin de générer une séquence à 6 états (pour les moteurs BLDC), deux transistors d'un bras doivent pouvoir être tous les deux ouverts en même temps. On doit donc utiliser deux sorties différentes d'un même timer. Pour piloter ce pont, il faut donc utiliser trois timers à deux sorties.

Nous retenons les quatre timers suivants et pour chacun, les bornes de sortie de l'Arduino indiquées :

  • Timer 8 : CH1 (D4), CH1N (D5), CH2 (D11), CH2N (D12), CH3 (D48), CH3N (D46).
  • Timer 2 : CH3 (D3), CH4 (D2).
  • Timer 3 : CH1 (D7), CH2 (D5).
  • Timer 4 : CH3 (D8), CH4 (D9).

Remarque : la sortie D5 est utilisée à la fois par le timer 8 et le timer 3. On pourra utiliser simultanément ces deux timers à condition de ne pas activer la sortie CH1N du timer 8 ou bien de ne pas activer la sortie CH2 du timer 3.

Notons aussi qu'un timer peut être utilisé simplement pour générer des interruptions périodiques et dans ce cas ses sorties ne sont pas nécessairement activées.

3.b. Fonctionnement des timers

Un timer utilise un compteur (16 bits dans le cas des timers 2,3,4 et 8). Le compteur est un registre 16 bits (CNT) incrémenté d'une unité à la période de l'horloge divisée par PSC+1 (prescaler). Dans le premier mode de fonctionnement (counter up), après que CNT a atteint la valeur de ARR (auto reload register), il revient à zéro. Dans le second mode de fonctionnement (counter up and down), après que CNT a atteint la valeur de ARR, il passe en mode décrémentation. Le registre CCR (capture/compare register) contient une valeur à laquelle CNT est comparé à chaque changement de sa valeur (incrémentation ou décrémentation). L'état de la sortie d'un timer (OCx) dépend du signe de CNT-CCR. La figure suivante montre les quatre manières de générer un signal PWM sur une sortie :

generation-pwm-fig.svgFigure pleine page

OCx désigne la sortie numéro x (1,2,3 ou 4). Chaque sortie a son propre registre CCRx. Un signal MLI avec alignement au bord (edge centered) est obtenu lorsque le compteur est seulement croissant. Dans ce cas, deux sorties dont les rapports cycliques diffèrent sont alignés sur un front montant. Un alignement au centre est obtenu lorsque le compteur est croissant puis décroissant. Dans ce cas, deux sorties dont les rapports cycliques diffèrent sont alignés sur le centre de l'état haut. Les modes 1 et 2 génèrent deux signaux complémentaires. L'intérêt de l'alignement au centre est de permettre l'ajout d'un temps mort entre le front montant d'un signal et le front descendant du signal complémentaire. Il suffit pour cela de changer légèrement la valeur de CCR pour le signal complémentaire. Le timer 8 génère automatiquement des signaux complémentaires (sorties CH1N, CH2N, CH3N) avec éventuellement ajout d'un temps mort.

La période du signal généré dépend de la période de l'horloge (que l'on nommera TC), du prescaler (PSC) et de ARR. Considérons tout d'abord le cas de l'alignement sur le bord (edge aligned). Le registre TCN est remis à zéro lorsqu'il atteint la valeur de ARR. La séquence périodique des valeurs prises par TCN est donc 0,1,...,ARR. Il s'en suit que la période est :

Tea=TC*(PSC+1)*(ARR+1)(1)

Dans le cas de l'alignement au centre, on a :

Tca=2*TC*(PSC+1)*(ARR+1)(2)

Le rapport cyclique du signal généré est, pour le mode 1 :

rc1=CRRARR(3)

et pour le mode 2 :

rc2=1-CRRARR(4)

La valeur de ARR, qui est inférieure ou égale à 0xFFFF, doit être la plus grande possible. En effet, plus cette valeur est grande plus le rapport cyclique peut être modulé avec précision. La fréquence de l'horloge utilisée par les timers est 240 MHz. Si PSC=1, on peut donc générer des signaux de période 10 microsecondes avec 2400 valeurs possibles du rapport cyclique, ce qui permet de le moduler très finement. Une telle précision est adaptée à la génération de signaux audio (ampli de classe D). Soient MAINCLOCK la fréquence de l'horloge en MHz et PERIOD la période du signal souhaitée. Soit a=1 pour le mode Edge aligned et a=2 pour le mode Center aligned. Le facteur d'horloge peut être calculé par la formule suivante :

PSC=E[PERIOD*MAINCLOCKa*0xFFFF](5)

Cette formule donne PSC=0 pour les périodes jusquà 0xFFFF*a*TC (soit 273,062 microsecondes pour a=1). Il faut ensuite calculer, pour ce PSC, la valeur de ARR qui permet d'obtenir la période la plus proche possible de la période souhaitée:

ARR = PERIOD*MAINCLOCKa*(PSC+1)-1(6)

Voici un exemple : une période souhaitée de 1000 microsecondes conduit à PSC=3 et ARR=59999. La période effective est bien en principe 1000 microsecondes mais la précision dépend de celle de l'horloge.

3.c. Configuration des sorties

Pour configurer une sortie d'un timer, il faut tout d'abord activer l'horloge pour cette sortie en mettant à 1 le bit correspondant dans le registre RCC->AHB4ENR (doc 3, page 492). Il faut ensuite configurer cette sortie en mode AF (Alternate Function) dans le registre GPIOx->MODER, où x est la lettre indiquant le groupe de la sortie (doc3 page 578). Le code 4 bits indiquant la fonction alternative doit être défini dans le registre GPIOx->AR[0] pour les sorties de numéro 0 à 7, dans le registre GPIOx->AR[1] pour les sorties de numéro 8 à 15 (doc 3 page 582). Ce code est accessible par une macro de la forme GPIO_AFm_TIMn où m est le numéro de l'AF (voir tableau ci-dessus) et n le numéro du timer. Par exemple, pour configurer la sortie CH1 du timer 3 sur le port PB4 (pin D7), il faut utiliser la macro GPIO_AF2_TIM3. Pour finir, la sortie doit être configurée en vitesse très rapide avec le registre GPIOx->OSPEEDR (doc 3 page 579).

Nous définissons une classe comportant des fonctions permettant de configurer la fonction alternative d'un port. Voici le fichier entête :

gpioconfig.h
#ifndef GPIOCONFIG
#define GPIOCONFIG
#include <Arduino.h>

#define ENABLE_CLOCK_PORTA RCC->AHB4ENR |= RCC_AHB4ENR_GPIOAEN;    // Enable GPIOA peripheral clock source
#define ENABLE_CLOCK_PORTB RCC->AHB4ENR |= RCC_AHB4ENR_GPIOBEN;    // Enable GPIOB peripheral clock source
#define ENABLE_CLOCK_PORTH RCC->AHB4ENR |= RCC_AHB4ENR_GPIOHEN;    // Enable GPIOH peripheral clock source
#define ENABLE_CLOCK_PORTJ RCC->AHB4ENR |= RCC_AHB4ENR_GPIOJEN;    // Enable GPIOJ peripheral clock source
#define ENABLE_CLOCK_PORTK RCC->AHB4ENR |= RCC_AHB4ENR_GPIOKEN;    // Enable GPIOK peripheral clock source

class GPIOConfig {
  public:
    GPIOConfig() {};
    void setAFportA(uint8_t p, uint8_t af); // p : numéro du port, af : alternate function
    void setAFportB(uint8_t p, uint8_t af);
    void setAFportJ(uint8_t p, uint8_t af);
    void setAFportK(uint8_t p, uint8_t af);
    void setAFportH(uint8_t p, uint8_t af);

};
#endif         
                     

Ce fichier contient également des macros permettant d'activer l'horloge pour un port. Voici le code source :

gpioconfig.cpp
#include "gpioconfig.h"
#include <Arduino.h>

void GPIOConfig::setAFportA(uint8_t p, uint8_t af) {

  uint16_t offset,i;
  if (p<8) {
    offset = 4*p;
    i=0;
  }
  else {
    offset = 4*(p-8);
    i = 1;
  }
  GPIOA->AFR[i] =  (GPIOA->AFR[i] & ~(0x15UL << offset)) | (af << offset);
  offset = 2*p;
  GPIOA->MODER = (GPIOA->MODER & ~(0x3UL << offset)) | (0x2UL << offset);
  GPIOA->OSPEEDR =  (GPIOA->OSPEEDR & ~(0x3UL << offset)) | (0x2UL << offset);
}

void GPIOConfig::setAFportB(uint8_t p, uint8_t af) {
  uint16_t offset,i;
  if (p<8) {
    offset = 4*p;
    i=0;
  }
  else {
    offset = 4*(p-8);
    i = 1;
  }
  GPIOB->AFR[i] =  (GPIOB->AFR[i] & ~(0x15UL << offset)) | (af << offset);
  offset = 2*p;
  GPIOB->MODER = (GPIOB->MODER & ~(0x3UL << offset)) | (0x2UL << offset);
  GPIOB->OSPEEDR =  (GPIOB->OSPEEDR & ~(0x3UL << offset)) | (0x2UL << offset);
}

void GPIOConfig::setAFportJ(uint8_t p, uint8_t af) {
  uint16_t offset,i;
  if (p<8) {
    offset = 4*p;
    i=0;
  }
  else {
    offset = 4*(p-8);
    i = 1;
  }
  GPIOJ->AFR[i] =  (GPIOJ->AFR[i] & ~(0x15UL << offset)) | (af << offset);
  offset = 2*p;
  GPIOJ->MODER = (GPIOJ->MODER & ~(0x3UL << offset)) | (0x2UL << offset);
  GPIOJ->OSPEEDR =  (GPIOJ->OSPEEDR & ~(0x3UL << offset)) | (0x2UL << offset);
}

void GPIOConfig::setAFportK(uint8_t p, uint8_t af) {
  uint16_t offset,i;
  if (p<8) {
    offset = 4*p;
    i=0;
  }
  else {
    offset = 4*(p-8);
    i = 1;
  }
  GPIOK->AFR[i] =  (GPIOK->AFR[i] & ~(0x15UL << offset)) | (af << offset);
  offset = 2*p;
  GPIOK->MODER = (GPIOK->MODER & ~(0x3UL << offset)) | (0x2UL << offset);
  GPIOK->OSPEEDR =  (GPIOK->OSPEEDR & ~(0x3UL << offset)) | (0x2UL << offset);
}

void GPIOConfig::setAFportH(uint8_t p, uint8_t af) {
  uint16_t offset,i;
  if (p<8) {
    offset = 4*p;
    i=0;
  }
  else {
    offset = 4*(p-8);
    i = 1;
  }
  GPIOH->AFR[i] =  (GPIOH->AFR[i] & ~(0x15UL << offset)) | (af << offset);
  offset = 2*p;
  GPIOH->MODER = (GPIOH->MODER & ~(0x3UL << offset)) | (0x2UL << offset);
  GPIOH->OSPEEDR =  (GPIOH->OSPEEDR & ~(0x3UL << offset)) | (0x2UL << offset);
}

                 
                     

3.d. Configuration du timer

L'horloge pour le timer doit être activée en mettant à 1 le bit correspondant dans le registre RCC->APB1LENR (pour les timers 2,3,4) ou bien RCC->APB2ENR (pour le timer 8), (doc 3 pages 496 et 502). Les sorties du timer que l'on souhaite utiliser doivent être configurées comme expliqué plus haut. En fonction de la période souhaitée, on calcule les valeurs de PSC et ARR, que l'on place dans les registres TIMn->PSC et TIMn->ARR (doc 3 pages 1866). Le timer 3 est un timer 32 bits (les autres sont 16 bits) mais nous l'utiliserons comme un timer 16 bits, c'est-à-dire que la valeur de ARR ne dépassera pas 0xFFFF.

La fonction capture/compare doit être configurée pour générer une sortie dans le registre TIMn->CCMR1 (doc 3 page 1859). Le mode de PWM (mode 1 ou 2) est également configuré dans ce registre. Le registre TIMn->CCMR1 est utilisé pour les sorties 1 et 2, le registre TIMn->CCMR2 pour les sorties 3 et 4. Enfin la sortie doit être activée dans le registre TIMn->CCER (doc 3 page 1863).

Pour déclencher le timer, il faut mettre à 1 le bit CEN du registre TIMn->CR1 (doc 3 page 1848). C'est aussi dans ce registre (2 bits CMS) que l'on configure le mode center aligned ou edge aligned. La raison de la présence de cette configuration dans ce registre est l'impossibilité de changer de mode lorsque le timer est en fonctionnement.

Pour le timer 8, le temps mort entre CH1 et CH1N, CH2 et CH2N, CH3 et CH3N, est configuré dans le registre TIMn->BDTR (doc 3 page 1782).

Le registre TIMn->DIER (doc 3 page 1853) permet d'activer différentes interruptions. Pour notre usage, nous considérons l'interruption UI (update interrupt) qui se déclenche lorsque TCN atteint ARR (donc une fois par cycle de signal PWM) en mode edge-aligned et lorsque TCN atteint ARR et 0 en mode center-aligned. Cette interruption sera utile pour effectuer la modulation de largeur d'impulsion (par changement de CCR). Par ailleurs, une utilisation courante d'un timer est de générer des interruptions périodiques (même s'il ne génère aucune sortie). Il ne suffit pas d'activer l'interruption dans le registre du timer, il faut également activer le traitement de l'interruption par le processeur dans le NVIC (nested vector interrupt controller) avec la fonction NVIC_EnableIRQ. Les noms des interruptions sont consultables doc 3 table 147 (page 798). Par exemple, la fonction de traitement pour le timer 2 est nommée TIM2 et il s'active par : NVIC_EnableIRQ(TIM2_IRQn). La fonction de traitement de l'interruption (ou ISR, interrupt service routine) doit être définie de la manière suivante :

extern "C" void TIM2_IRQHandler() {
  TIM2->SR &= ~TIM_SR_UIF;
  // action à exécuter lors de l'interruption
}
                             

Pour le compilateur, TIM2_IRQHandler désigne l'emplacement précis de la mémoire où il faut placer le code de la fonction. Lorsque l'interruption se déclenche, le processeur exécute les instructions débutant à cette adresse. La mise à zéro du bit TIM_SR_UIF dans le registre TIMn->SR indique que l'interruption a bien été traitée. Sans cette mise à zéro, la fonction d'interruption est appelée en boucle.

Si l'on veut utiliser plusieurs timers simultanément, il peut être nécessaire de les synchroniser. Une action sur un timer peut être déclenchée par un autre timer via un canal ITR (Internal Trigger). La table 346 (doc 3 page 1764) nous informe que TIM8 peut être déclenché par TIM2(ITR1) et par TIM4(ITR2). La table 351 (doc 3 page 1853) nous informe que TIM2 peut être déclenché par TIM8(ITR1), TIM3(ITR2) et TIM4(ITR3). Elle nous informe aussi que TIM3 peut être déclenché par TIM2(ITR1) et TIM4(ITR3), et que TIM4 peut être déclenché par TIM2 (ITR1), TIM3(ITR2), TIM8(ITR3). Nous choisissons donc un mode synchrone (optionnel) dans lequel le timer 2 est maître et les timers 8,3 et 4 sont esclaves. Pour le timer 2, il faut choisir le mode maître dans les bits MMS du registre TIM2->CR2 (doc 3, page 1849). Nous choisissons le mode Enable car nous voulons que le timer déclenche un signal au moment de son propre déclenchement. Ce signal est utilisé pour déclencher les autres timers. Il faut aussi activer le bit MMS dans TIM2->SMCR. Pour configurer un timer en esclave, il faut préciser la source du déclenchement dans les bits TS de TIMn->SMCR (doc 3, page 1852) : on choisit ITR1 puisque c'est par ce canal que le signal en provenance de TIM2 est envoyé vers les autres timers. Les bits SMS du même registre (Salve Mode Selection) permettent de choisir l'action à déclencher sur le timer esclave : nous choissons Trigger Mode, qui signifie que le timer sera déclenché lorsque un front montant sera reçu sur ITR1. Pour qu'un timer fonctionne en esclave, il ne faut pas activer le bit CEN de son registre TIMn->CR1 : celui-ci est automatiquement activé par l'activation du même bit pour le timer maître.

Afin de programmer facilement les timers, nous écrivons une classe TimerPWM. Voici le fichier entête :

timerpwm.h
#ifndef TIMERPWM
#define TIMERPWM
#include <Arduino.h>

#define MAIN_CLOCK 240 // MHz

class TimerPWM {
  private:
    TIM_TypeDef * tim;
    void CC1config(uint8_t cc);
    void CC2config(uint8_t cc);
    void CC3config(uint8_t cc);
    void CC4config(uint8_t cc);
    
  public:
    uint8_t timnum; // numéro du timer
    uint8_t nchannels; // nombre de sorties (1,2 ou 3)
    uint16_t psc;// clock prescaler
    uint16_t arr; // auto-reload register
    uint16_t ccr1,ccr2,ccr3,ccr4;
    double clock_period, period;
    bool center;
    TimerPWM(uint8_t timnum, uint8_t nchan, double period, float *duty,  bool *invert, bool interrupt, bool center, uint8_t deadtime, bool synchro);
    void init(double delay);
    void start();
    void stop();
    void setPeriod(double period);
};

#endif   
                              

Les arguments du constructeur sont :

  • timnum : le numéro du timer (2,3,4 ou 8).
  • nchan : nombre de voies de sortie.
  • duty : tableau contenant les rapports cycliques (initiaux), un pour chaque voie.
  • invert : tableau contenant, pour chaque voie, false pour le mode 1, true pour le mode 2.
  • interrupt : activation de l'interruption (update interrupt).
  • center : mode center-aligned.
  • deadtime : temps mort.
  • synchro : synchronisation des déclenchements, avec timer 2 en maître, les autres en esclave.

Le constructeur configure le timer mais ne le démarre pas. Le démarrage est accompli par la fonction start. Par défaut, la valeur initiale de CNT est nulle. Il est possible de modifier cette valeur avec la fonction init, dont l'argument delay définit un délai (en microsecondes). Modifier la valeur de CNT permet en effet de décaler temporellement le signal généré, ce qui est utile lorsque plusieurs timers sont démarrés simultanément. La fonction init peut être appelée lorsque le timer est en fonctionnement. La fonction setPeriod permet de modifier la période, que le timer soit arrêté ou en cours de fonctionnement.

Voici le code source :

timerpwm.cpp


#include "timerpwm.h"
#include "Arduino.h"
#include "gpioconfig.h"


void TimerPWM::CC1config(uint8_t oc) {
  tim->CCMR1 = (tim->CCMR1 & ~(0xFF )) | oc; // compare mode register, OC1 config
  tim->CCER |= TIM_CCER_CC1E ; // capture/compare enable register, OC1 enable
}

void TimerPWM::CC2config(uint8_t oc) {
  tim->CCMR1 = (tim->CCMR1 & ~(0xFF <<8 )) | (oc << 8); // OC2 config
  tim->CCER |= TIM_CCER_CC2E ; // capture/compare enable register, OC2 enable
}

void TimerPWM::CC3config(uint8_t oc) {
  tim->CCMR2 = (tim->CCMR2 & ~(0xFF )) | oc; // compare mode register, OC1 config
  tim->CCER |= TIM_CCER_CC3E ; // capture/compare enable register, OC1 enable
}

void TimerPWM::CC4config(uint8_t oc) {
  tim->CCMR2 = (tim->CCMR2 & ~(0xFF <<8 )) | (oc << 8); // OC2 config
  tim->CCER |= TIM_CCER_CC4E ; // capture/compare enable register, OC2 enable
}

TimerPWM::TimerPWM(uint8_t num, uint8_t nchan, double period, float *duty, bool *invert, bool interrupt, bool center, uint8_t deadtime, bool synchro) {
  timnum = num;
  this->center = center;
  switch (timnum) {
    case 2:
      if (nchan>2) nchan=2;
      tim = TIM2;
      break;
    case 3:
      if (nchan>2) nchan=2;
      tim = TIM3;
      break;
    case 4:
      if (nchan>2) nchan=2;
      tim = TIM4;
      break;
    case 8:
      if (nchan>3) nchan=3;
      tim = TIM8;
      break;
    default:
      return;
  }
  GPIOConfig gpio;
  nchannels = nchan;
  uint8_t a = 1;
  if (center) a = 2;
  psc = period/a*MAIN_CLOCK/0xFFFF;
  arr = period*MAIN_CLOCK/((psc+1)*a)-1;
  this->period = period;
  if (nchan>=1) ccr1 = arr*duty[0];
  if (nchan>=2) ccr2 = arr*duty[1];
  if (nchan>=3) ccr3 = arr*duty[2];
  uint8_t oc[3];
  for (uint8_t i=0; i<nchan; i++) {
    //if (invert[i]) oc[i]=0x78; else oc[i]=0x68;
    if (invert[i]) oc[i]=0x70; else oc[i]=0x60;
  }
  
  switch(timnum) {
    case 2:
      RCC->APB1LENR |= RCC_APB1LENR_TIM2EN;    // Enable TIM2 peripheral clock source
      ENABLE_CLOCK_PORTA
      if (nchan>=1) gpio.setAFportA(2,GPIO_AF1_TIM2); // pin D3, PA2, TIM2_CH3
      if (nchan>=2) gpio.setAFportA(3,GPIO_AF1_TIM2); // pin D2, PA3, TIM2_CH4
      break;
    case 3:
      RCC->APB1LENR |= RCC_APB1LENR_TIM3EN;    // Enable TIM3 peripheral clock source
      ENABLE_CLOCK_PORTA
      ENABLE_CLOCK_PORTB
      if (nchan>=1) gpio.setAFportB(4,GPIO_AF2_TIM3); // pin D7, PB4, TIM3_CH1
      if (nchan>=2) gpio.setAFportA(7,GPIO_AF2_TIM3); // pin D5, PA7, TIM3_CH2
      break;
    case 4:
      RCC->APB1LENR |= RCC_APB1LENR_TIM4EN;    // Enable TIM4 peripheral clock source
      ENABLE_CLOCK_PORTB
      if (nchan>=1) gpio.setAFportB(8,GPIO_AF2_TIM4); // pin D8, PB8, TIM4_CH3
      if (nchan>=2) gpio.setAFportB(9,GPIO_AF2_TIM4); // pin D9, PB9, TIM4_CH4
      break;
    case 8:
      RCC->APB2ENR |= RCC_APB2ENR_TIM8EN;    // Enable TIM8 peripheral clock source
      if (nchan>=1) {
        ENABLE_CLOCK_PORTJ
        ENABLE_CLOCK_PORTA
        gpio.setAFportJ(8,GPIO_AF3_TIM8); // pin D4, port J8, TIM8_CH1
        gpio.setAFportA(7,GPIO_AF3_TIM8); // pinc D5, port A7, TIM8_CH1N
      }
      if (nchan>=2) {
        gpio.setAFportJ(10,GPIO_AF3_TIM8); // pin D11, port J10, TIM8_CH2
        gpio.setAFportJ(11,GPIO_AF3_TIM8); // pin D12, port J11, TIM8_CH2N
      }
      if (nchan>=3) {
        ENABLE_CLOCK_PORTK
        ENABLE_CLOCK_PORTH
        gpio.setAFportK(0,GPIO_AF3_TIM8); // pin D48, port K0, TIM8_CH3
        gpio.setAFportH(15,GPIO_AF3_TIM8); // pin D46, port H15, TIM8_CH3N
      }
      break;
  }
  
  __disable_irq();
  tim->CR1 = 0;
  if (timnum==8) tim->BDTR = TIM_BDTR_MOE;
  tim->RCR = 0;
  tim->PSC = psc;
  tim->ARR = arr;
  
  
  switch(timnum) {
    case 2:
      if (nchan>=1) {CC3config(oc[0]); tim->CCR3 = ccr3 = ccr1;}
      if (nchan>=2) {CC4config(oc[1]); tim->CCR4 = ccr4 = ccr2;}
      if (interrupt) NVIC_EnableIRQ(TIM2_IRQn);
      break;
    case 3: 
      if (nchan>=1) {CC1config(oc[0]); tim->CCR1 = ccr1;}
      if (nchan>=2) {CC2config(oc[1]); tim->CCR2 = ccr2;}
      if (interrupt) NVIC_EnableIRQ(TIM3_IRQn);
      break;
    case 4:
      if (nchan>=1) {CC3config(oc[0]); tim->CCR3 = ccr3 = ccr1;}
      if (nchan>=2) {CC4config(oc[1]); tim->CCR4 = ccr4 = ccr2;}
      if (interrupt) NVIC_EnableIRQ(TIM4_IRQn);
      break;
    case 8:
      if (nchan>=1) {CC1config(oc[0]); tim->CCER |= TIM_CCER_CC1NE; tim->CCR1 = ccr1;}
      if (nchan>=2) {CC2config(oc[1]); tim->CCER |= TIM_CCER_CC2NE; tim->CCR2 = ccr2;}
      if (nchan>=3) {CC3config(oc[2]); tim->CCER |= TIM_CCER_CC3NE; tim->CCR3 = ccr3;}
      if (interrupt) NVIC_EnableIRQ(TIM8_UP_TIM13_IRQn);
      break;
    
  }
  
  tim->BDTR |= (tim->BDTR & ~0xFF) | deadtime;
  tim->SR = 0;
  if (interrupt) tim->DIER |= TIM_DIER_UIE; // update interrupt enable 
  tim->CR1 &= ~TIM_CR1_CMS;
  if (center) tim->CR1 |= (1 << TIM_CR1_CMS_Pos);
  if (synchro) {
    if (timnum==2) {// master
      tim->CR2 = (tim->CR2 & ~TIM_CR2_MMS) | (1 << TIM_CR2_MMS_Pos); // Master mode selection : Enable (signal CNT_EN is used as trigger output (TRGO))
      tim->SMCR = (tim->SMCR & ~TIM_SMCR_MSM) | TIM_SMCR_MSM; // master slave mode
    }
    else { //slave
      tim->SMCR = (tim->SMCR & ~TIM_SMCR_TS) | (1 << TIM_SMCR_TS_Pos); // internal trigger ITR1
      tim->SMCR = (tim->SMCR & ~TIM_SMCR_SMS) | (6 << TIM_SMCR_SMS_Pos);// slave mode selection : trigger mode
    }
  }
  tim->CNT = 0;
  __enable_irq();

}

void TimerPWM::init(double delay) {
  uint8_t a = 1;
  if (center) a = 2;
  tim->CNT = delay/period * a*arr;
}

void TimerPWM::start() {
  tim->CR1 |= TIM_CR1_CEN;
}

void TimerPWM::stop() {
  tim->CR1 &= ~TIM_CR1_CEN;
}

void TimerPWM::setPeriod(double period) {
  uint8_t a = 1;
  if (center) a = 2;
  psc = period/a*MAIN_CLOCK/0xFFFF;
  arr = period*MAIN_CLOCK/((psc+1)*a)-1;
  this->period = period;
  tim->PSC = psc;
  tim->ARR = arr;
}

                         
                              

3.e. Exemples

Le programme suivant fait fonctionner le timer 2 avec une période de 100 microsecondes. Sur la sortie CH3 (D3), il génère un signal créneau de rapport cyclique 0,2. Sur la sortie CH4 (D2) il génère un signal de rapport cyclique 0,5. Par ailleurs, on utilise l'interruption qui se produit à chaque cycle pour calculer le temps écoulé depuis le déclenchement du timer. Le temps est affiché dans la boucle principale toutes les 100 ms.

timer2-pwm-chrono.ino
#include "timerpwm.h"
TimerPWM *timer2;

uint32_t compteur;
float temps;

extern "C" void TIM2_IRQHandler() {
  TIM2->SR &= ~1;
  compteur++;
  temps = 1e-4*compteur;
}


void setup() {
  Serial.begin(115200);
  while (!Serial);
  float duty[3] = {0.2,0.5,0.5};
  bool invert[3] = {false,false,false};
  compteur = 0;
  temps = 0;
  bool interrupt = true;
  bool center = false;
  timer2 = new TimerPWM(2,2,100,duty,invert,interrupt,center,0,false);
  timer2->start();
}

void loop() {
  delay(100);
  Serial.println(temps);
}

                

Voici les signaux sur D2 et D3, numérisés avec l'Analog Discovery 2 et le logiciel Waveforms :

[t,u1,u2] = np.loadtxt("timer2-100us.txt",unpack=True,skiprows=1)
figure(figsize=(16,8))
subplot(211)
plot(t*1e3,u1)
grid()
ylabel("CH3(D3)",fontsize=16)
subplot(212)
plot(t*1e3,u2)
ylabel("CH4(D2)",fontsize=16)
grid()
xlabel("t (ms)",fontsize=16)
                
fig3fig3.pdf

Voici les signaux lorsque center=true (mode center-aligned) :

[t,u1,u2] = np.loadtxt("timer2-100us-center.txt",unpack=True,skiprows=1)
figure(figsize=(16,8))
subplot(211)
plot(t*1e3,u1)
grid()
ylabel("CH3(D3)",fontsize=16)
subplot(212)
plot(t*1e3,u2)
ylabel("CH4(D2)",fontsize=16)
grid()
xlabel("t (ms)",fontsize=16)
                
fig4fig4.pdf

Lorsque le mode center-aligned est sélectionné, l'évènement update se produit aussi lorsque le compteur parvient à zéro. L'interruption est donc déclenchée deux fois par cycle et il faut donc écrire dans la fonction de traitement de l'interruption : temps = 0.5e-4*compteur;.

Voici les signaux pour une période de 10 microsecondes (mode edge-aligned) :

[t,u1,u2] = np.loadtxt("timer2-10us.txt",unpack=True,skiprows=1)
figure(figsize=(16,8))
subplot(211)
plot(t*1e3,u1)
grid()
ylabel("CH3(D3)",fontsize=16)
subplot(212)
plot(t*1e3,u2)
ylabel("CH4(D2)",fontsize=16)
grid()
xlabel("t (ms)",fontsize=16)
                
fig5fig5.pdf

Pour tester les 6 sorties du timer 8, nous ajoutons les lignes suivantes :

timer8 = new TimerPWM(8,3,10,duty,invert,false,true,0,false);
timer8->start();
                

L'analyseur logique de l'Analog Discovery 2 permet d'acquérir les 6 signaux (en tant que signaux numériques) :

[t,u1,u2,u3,u4,u5,u6] = np.loadtxt("timer8-10us.txt",unpack=True,skiprows=1)
figure(figsize=(16,12))
subplot(611)
plot(t*1e3,u1)
grid()
ylabel("CH1(D4)",fontsize=16)
subplot(612)
plot(t*1e3,u2)
ylabel("CH1N(D5)",fontsize=16)
grid()
subplot(613)
plot(t*1e3,u3)
grid()
ylabel("CH2(D11)",fontsize=16)
subplot(614)
plot(t*1e3,u4)
ylabel("CH2N(D12)",fontsize=16)
grid()
subplot(615)
plot(t*1e3,u5)
grid()
ylabel("CH3(D48)",fontsize=16)
subplot(616)
plot(t*1e3,u6)
ylabel("CH3N(D46)",fontsize=16)
grid()
xlabel("t (ms)",fontsize=16)
                
fig6fig6.pdf

Le temps mort (DT) entre un signal et son complémentaire (par exemple entre CH1 et CH1N) est défini par un nombre entier 8 bits, que l'on notera DTG. Si le dernier bit de DTG vaut 0 (si DTG est compris entre 0 et 127), on a DT = TDG*TC. Dans le cas présent TC=1/240 microsecondes (horloge à 240 MHz). Un TDG de 120 s'obtient par :

timer8 = new TimerPWM(8,3,10,duty,invert,false,true,120,false);
timer8->start();
                

Voici le résultat :

[t,u1,u2,u3,u4,u5,u6] = np.loadtxt("timer8-10us-DTG120.txt",unpack=True,skiprows=1)
figure(figsize=(16,12))
subplot(611)
plot(t*1e3,u1)
grid()
ylabel("CH1(D4)",fontsize=16)
subplot(612)
plot(t*1e3,u2)
ylabel("CH1N(D5)",fontsize=16)
grid()
subplot(613)
plot(t*1e3,u3)
grid()
ylabel("CH2(D11)",fontsize=16)
subplot(614)
plot(t*1e3,u4)
ylabel("CH2N(D12)",fontsize=16)
grid()
subplot(615)
plot(t*1e3,u5)
grid()
ylabel("CH3(D48)",fontsize=16)
subplot(616)
plot(t*1e3,u6)
ylabel("CH3N(D46)",fontsize=16)
grid()
xlabel("t (ms)",fontsize=16)
                
fig7fig7.pdf

Le décalage entre le front montant d'un signal et le front descendant du signal complémentaire est bien de 120/240 microsecondes. Lorsque le premier bit de DTG vaut 1, (valeur comprise entre 128 et 255), la formule de calcul est différente (voir doc 3 page 1785). Par ailleurs le temps de base servant au calcul, égal à TC par défaut, peut être modifié dans le registre TIMn->CR1 : les valeurs possibles sont 2*TC et 4*TC.

Afin de tester la synchronisation des timers, nous utilisons la sortie CH3(D3) du timer 2 (maître) et la sortie CH1(D4) du timer 8 (esclave). On commence par faire fonctionner les deux timers sans synchronisation du déclenchement, le timer 2 étant déclenché juste après le timer 8 :

  timer2 = new TimerPWM(2,1,periode_pwm,duty,invert,false,false,0,false);
  timer8 = new TimerPWM(8,1,periode_pwm,duty,invert,false,false,0,false); 
  timer8->start();
  timer2->start();
                 
[t,u1,u2] = np.loadtxt("timer2-timer8-1.txt",unpack=True,skiprows=1)
figure(figsize=(16,6))
plot(t*1e6,u1,label="TIM2(CH3)")
plot(t*1e6,u2+4,label="TIM8(CH1)")
grid()
xlabel(r"$t\ \rm(\mu s)$",fontsize=16)
                 
fig8fig8.pdf

Les deux signaux sont bien synchrones, ce qui est logique puisque les deux timers sont pilotés par la même horloge et sont configurés avec la même période. Voici une vue détaillée des fronts montants :

[t,u1,u2] = np.loadtxt("timer2-timer8-2.txt",unpack=True,skiprows=3)
figure(figsize=(16,6))
plot(t,u1,label="TIM2(CH3)")
plot(t,u2+4,label="TIM8(CH1)")
grid()
xlim(-200,200)
xlabel(r"$t\ \rm(ns)$",fontsize=16)
                 
fig9fig9.pdf

Le décalage entre les deux fronts montants est 80 ns, ce qui peut être considéré comme négligeable par rapport à la période (100 us). L'utilisation de la fonction delayMicroseconds permet d'augmenter ce délai :

  timer2 = new TimerPWM(2,1,periode_pwm,duty,invert,false,false,0,false);
  timer8 = new TimerPWM(8,1,periode_pwm,duty,invert,false,false,0,false); 
  timer8->start();
  delayMicroseconds(10);
  timer2->start();
                 
[t,u1,u2] = np.loadtxt("timer2-timer8-3.txt",unpack=True,skiprows=3)
figure(figsize=(16,6))
plot(t,u1,label="TIM2(CH3)")
plot(t,u2+4,label="TIM8(CH1)")
grid()
xlabel(r"$t\ \rm(us)$",fontsize=16)
                 
fig10fig10.pdf

Testons à présent la synchronisation du déclenchement :

  timer2 = new TimerPWM(2,1,periode_pwm,duty,invert,false,false,0,true);
  timer8 = new TimerPWM(8,1,periode_pwm,duty,invert,false,false,0,true); 
  timer2->start();
                 
[t,u1,u2] = np.loadtxt("timer2-timer8-4.txt",unpack=True,skiprows=3)
figure(figsize=(16,6))
plot(t,u1,label="TIM2(CH3)")
plot(t,u2+4,label="TIM8(CH1)")
grid()
xlim(-200,200)
xlabel(r"$t\ \rm(ns)$",fontsize=16)
                 
fig11fig11.pdf

Les fronts montants sont bien simultanés. Pour obtenir un décalage temporel entre les deux signaux, on utilise la fonction init, qui permet d'ajuster la valeur initiale du compteur :

  timer2 = new TimerPWM(2,1,periode_pwm,duty,invert,false,false,0,true);
  timer8 = new TimerPWM(8,1,periode_pwm,duty,invert,false,false,0,true); 
  timer8->init(25);
  timer2->start();
                 
[t,u1,u2] = np.loadtxt("timer2-timer8-5.txt",unpack=True,skiprows=3)
figure(figsize=(16,6))
plot(t,u1,label="TIM2(CH3)")
plot(t,u2+4,label="TIM8(CH1)")
grid()
xlabel(r"$t\ \rm(us)$",fontsize=16)
                 
fig12fig12.pdf

Remarquons que l'obtention de deux signaux PWM déphasés n'est possible qu'avec deux timers différents mais pas avec les différentes sorties d'un même timer.

4. Applications

4.a. Onduleur MLI monophasé

La modulation de largeur d'impulsion (MLI) consiste à moduler le rapport cyclique. Dans le cas d'un onduleur, la modulation est sinusoïdale mais une modulation de forme quelconque peut être envisagée (mise en œuvre dans les amplificateurs audio de classe D). Voir Onduleur monophasé MLI pour la réalisation d'un onduleur monophasé piloté par un arduino MEGA.

Soit fmax la fréquence maximale du spectre du signal modulant. La fréquence de découpage fd, c'est-à-dire la fréquence du signal PWM, doit être beaucoup plus grande que fmax (un facteur 100 ou plus est souhaitable). La charge est le plus souvent inductive et réalise un filtrage passe-bas qui atténue beaucoup plus (au moins 40 dB) la fréquence de découpage que la fréquence fmax. Si le filtrage est réalisé par la charge inductive (filtre d'ordre 1), le meilleur résultat est obtenu lorsque fd est juste au dessus de la fréquence de coupure.

Un onduleur monophasé est réalisé au moyen d'un pont de transistors en H (4 transistors) :

pont4transistors-fig.svgFigure pleine page

Ce pont est commandé par un signal PWM (dont on fait varier le rapport cyclique) et par son signal complémentaire. Tout timer à deux sorties convient pour cette tâche mais nous utilisons le timer 8 car celui-ci permettra de piloter un pont de 6 transitors pour obtenir un onduleur triphasé. Nous utilisons la sortie CH1 du timer 8, lequel génère automatiquement un signal complémentaire sur CH1N, avec possibilité d'ajouter un temps mort. Lorsqu'on utilise un microcontrôleur 8 bits dont le processeur ne possède pas d'unité de calcul en virgule flottante (Arduino MEGA), la modulation se fait au moyen d'une table précalculée contenant les valeurs de CCR (entiers 16 bits). La mise à jour de CCR se fait à chaque interruption (une ou deux par cycle) avec calcul d'une phase au moyen d'un accumulateur de phase. Cette technique est mise en œuvre dans Onduleur monophasé MLI. Avec l'Arduino GIGA R1, nous disposons d'un processeur 32 bits (Cortex M7, horloge à 480 MHz) dont la vitesse de calcul (en particulier sur les nombres réels à virgule flottante) est beaucoup plus grande que celle de l'Arduino MEGA. Nous pouvons donc envisager de calculer la valeur de CCR en temps réel (à chaque interruption) et de faire ces calculs en virgules flottantes. Cela permettra en outre d'effectuer la modulation par un signal délivré en temps réel, par exemple un signal audio.

Soit s(t) le signal modulant, échantillonné à la fréquence des interruptions, soit deux fois par cycle du signal PWM. Le temps peut être calculé directement comme montré plus haut. On suppose s(t) varie entre -1 et 1. La valeur de CCR doit varier de 0 à ARR. On a donc :

CCR(t) = ARR2(1+s(t))(7)

Voici le programme, avec une fréquence de PWM de 50 kHz (fréquence de découpage) :

timer8-modulation.ino
#include "timerpwm.h"
TimerPWM *timer8;

uint32_t compteur;
float temps;
float periode_pwm = 10; // microsecondes
float freq = 100.0; // fréquence du signal
float omega;

float signal(float t) {
  return 0.5*sin(omega*t);
}

extern "C" void TIM8_UP_TIM13_IRQHandler() {
  TIM8->SR &= ~1; 
  compteur++;
  temps = periode_pwm*0.5*1e-6*compteur;
  TIM8->CCR1 = timer8->arr * 0.5 * (1+signal(temps));
}


void setup() {
  Serial.begin(115200);
  while (!Serial);
  omega = 2*PI*freq;
  float duty[1] = {0.5};
  bool invert[1] = {false};
  compteur = 0;
  timer8 = new TimerPWM(8,1,periode_pwm,duty,invert,true,true,0);
  timer8->start();
}

void loop() {
  delay(100);
  Serial.println(temps);

}
                      

Pour tester le fonctionnement de la modulation, on place en sortie CH1(D4) un filtre RC passe-bas avec R=1,5 et C=100nF, soit une fréquence de coupure de 1060 Hz, environ 100 fois plus petite que la fréquence de découpage. Voici le signal en sortie du filtre :

[t,v1] = np.loadtxt("timer8-10us-modulation.txt",unpack=True,skiprows=1)
figure(figsize=(16,6))
plot(t*1e3,v1) 
ylim(0,3.3)
grid()
xlabel("t (ms)",fontsize=16)
ylabel("CH1 filtré (V)",fontsize=16)  
                        
fig13fig13.pdf

Comme prévu, la période de l'ondulation est 10 ms. Remarque : la tension aux bornes d'une charge inductive en sortie du pont est alternative. Cet exemple montre les performances du microcontrôleur STM32H747XI (avec le processeur Cortex M7) : il gère sans erreurs les interruptions à 200 kHz tout en affichant le temps sur la console sans erreur apparente. Il faut remarquer que 100 kHz est une fréquence élevée pour un pont de transistors MOSFET et peut être considérée comme la valeur maximale réalisable avec ce type de ponts. Voici le résultat pour un signal modulant de 1000 Hz :

[t,v1] = np.loadtxt("timer8-10us-modulation2.txt",unpack=True,skiprows=1)
figure(figsize=(16,6))
plot(t*1e3,v1) 
ylim(0,3.3)
grid()
xlabel("t (ms)",fontsize=16)
ylabel("CH1 filtré (V)",fontsize=16)  
                        
fig14fig14.pdf

À cette fréquence, le découpage commence à peine à être visible. Il semble donc possible de réaliser la modulation par un signal audio (en limitant la fréquence à 1000 Hz). L'Arduino GIGA est donc tout à fait adapté à la réalisation d'un Amplificateur audio de classe D.

4.b. Onduleur diphasé

L'onduleur diphasé désigne un double onduleur monophasé délivrant deux tensions sinuoïdales en quadrature. Il permet de produire un champ magnétique tournant au moyen de deux paires de bobines disposées perpendiculairement. Voir Onduleur diphasé MLI pour bobines.

On utilise le timer 8 comme précédemment mais on ajoute la sortie CH2, avec une modulation déphasée de 90 degrés sur CH2 par rapport à CH1. Ce type d'onduleur s'utilise plutôt à très basse fréquence de modulation (moins de 100 Hz). On peut donc abaisser la fréquence de découpage à 10 kHz et réduire d'un facteur 10 la fréquence de coupure du filtre.

timer8-100us-modulation2phases.ino
#include "timerpwm.h"
TimerPWM *timer8;

uint32_t compteur;
float temps;
float periode_pwm = 100; // microsecondes
float freq = 100.0;
float omega;
float phi;



extern "C" void TIM8_UP_TIM13_IRQHandler() {
  TIM8->SR &= ~1;
  inter++;
  compteur++;
  temps = periode_pwm*0.5*1e-6*compteur;
  float phase = omega*temps;
  TIM8->CCR1 = timer8->arr * 0.5 * (1+0.5*sin(phase));
  TIM8->CCR2 = timer8->arr * 0.5 * (1+0.5*sin(phase+phi));
}


void setup() {
  Serial.begin(115200);
  while (!Serial);
  omega = 2*PI*freq;
  phi = PI/2;
  float duty[2] = {0.5,0.5};
  bool invert[2] = {false,false};
  compteur = 0;
  timer8 = new TimerPWM(8,2,periode_pwm,duty,invert,true,true,0);
  timer8->start();
  
}

void loop() {

}
                 
                  
[t,v1,v2] = np.loadtxt("timer8-10us-modulation2phases.txt",unpack=True,skiprows=1)
figure(figsize=(16,6))
plot(t*1e3,v1,label="CH1(D4) filtré") 
plot(t*1e3,v2,label="CH2(D11) filtré") 
ylim(0,3.3)
grid()
legend(loc="upper right")
xlabel("t (ms)",fontsize=16)
ylabel("Volts",fontsize=16)  
                        
fig15fig15.pdf

4.c. Onduleur triphasé

Un onduleur triphasé est réalisé avec un pont de 6 transistors (et non pas trois ponts de 4 transistors). CH1,CH1N pilote le premier demi-pont, CH2,CH2N pilote le deuxième et CH3,CH3 pilote le troisième. Les modulations sinusoïdales de CH1,CH2,CH3 sont déphasées de 120 degrés.

timer8-modulation3phases.ino
#include "timerpwm.h"
TimerPWM *timer8;

uint32_t compteur;
float temps;
float periode_pwm = 100; // microsecondes
float freq = 100.0;
float omega;
float phi;



extern "C" void TIM8_UP_TIM13_IRQHandler() {
  TIM8->SR &= ~1;
  GPIOH->BSRR |= GPIO_BSRR_BS6; //digitalWrite(13,HIGH);
  compteur++;
  temps = periode_pwm*0.5*1e-6*compteur;
  float phase = omega*temps;
  TIM8->CCR1 = timer8->arr * 0.5 * (1+0.5*sin(phase));
  TIM8->CCR2 = timer8->arr * 0.5 * (1+0.5*sin(phase+phi));
  TIM8->CCR3 = timer8->arr * 0.5 * (1+0.5*sin(phase-phi));
  GPIOH->BSRR |= GPIO_BSRR_BR6; //digitalWrite(13,LOW);
}


void setup() {
  Serial.begin(115200);
  pinMode(13,OUTPUT);
  while (!Serial);
  omega = 2*PI*freq;
  phi = 2*PI/3;
  float duty[2] = {0.5,0.5,0.5};
  bool invert[2] = {false,false,false};
  compteur = 0;
  timer8 = new TimerPWM(8,3,periode_pwm,duty,invert,true,true,0);
  timer8->start();
}

void loop() {

}
                 

Si l'onduleur se limite à générer des tensions sinusoïdales, il serait plus efficace de stocker les valeurs de CCR dans une table et de parcourir cette table au moyen d'un accumulateur de phase (voir onduleur triphasé). En effet, l'implémentation ci-dessus comporte 3 appels de la fonction sinus dans l'interruption. En tout cas, il est important de connaître la durée d'exécution d'une ISR (interrupt service routine). Nous avons pour cela ajouté une mise au niveau haut de la sortie D13 (PH6) au début de cette fonction suivie d'une mise au niveau bas à la fin. L'observation du signal sur D13 montre que la durée d'exécution de cette fonction est de 15 microsecondes. Le programme ci-dessus ne peut fonctionner que si l'ISR a le temps de s'exécuter avant que l'interruption suivante soit déclenchée. Avec une marge de sécurité de 5 microsecondes, on peut opter pour une durée minimale entre deux interruptions consécutives de 20 microsecondes, soit une période de PWM de 40 microsecondes (car il y a deux interruptions par cycle). Voici le signal en sortie D13 dans ce cas :

[t,v1,v2] = np.loadtxt("timer8-40us-modulation3phasesD13.txt",unpack=True,skiprows=1)
figure(figsize=(16,4))
plot(t*1e6,v2,label="D13") 
ylim(-1,4)
grid()
legend(loc="upper right")
xlabel("t (us)",fontsize=16)
ylabel("Volts",fontsize=16)  
                        
fig16fig16.pdf

4.d. Commutateur triphasé à 6 états

Un commutateur triphasé à 6 états permet de piloter un moteur synchrone triphasé de type BLDC. Le pont comporte 6 transistors (3 bras de 2 transistors) comme l'onduleur triphasé. Cependant, une des trois phases est déconnectée lorsque le courant passe entre les deux autres. Une phase est déconnectée lorsque les deux transistors du bras correspondant sont tous les deux à l'état bloqué. En conséquence, les deux transistors d'un bras doivent être pilotés par deux sorties indépendantes d'un timer et non pas simplement par deux sorties complémentaires. Pour effectuer cette commutation, nous utilisons donc trois timers différents, les timers 2,3 et 4. Voici le schéma du pont avec les sorties utilisées pour commander les transistors :

commutation6etats-fig.svgFigure pleine page

La figure ci-dessus représente un des 6 états : le potentiel de A est nul, celui de B est Vs et celui de C n'est pas imposé. Les deux transistors du bras B sont alternativement ouverts et fermés à la fréquence de découpage, donc les sorties TIM3_CH1 et TIM3_CH2 doivent délivrer deux signaux PWM complémentaires. La tension effective du point B est égale à Vs multipliée par le rapport cyclique.

Les signaux de commande des deux transistors d'un bras sont complémentaires sauf lorsque les deux transistors sont bloqués (interrupteurs ouverts). Lorsqu'un timer est en train de générer un signal PWM sur une sortie, il est aisé d'annuler la tension de cette sortie en imposant CCR=0 pour cette sortie (en mode pwm 1) ou bien en imposant CCR=ARR+1 (en mode pwm2).

timer8-commutation6etats.ino
#include "timerpwm.h"
TimerPWM *timer2;
TimerPWM *timer3;
TimerPWM *timer4;

float periode_pwm = 100; // microsecondes

// bras de pont phase A
#define INA_1 2 // transistor haut
#define INA_2 3 // transistor bas
// bras de pont phase B
#define INB_1 5 
#define INB_2 7 
// bras de pont phase C
#define INC_1 9 
#define INC_2 8 

// états d'un bras de pont
#define HIGH_0_LOW_1 0 // sortie à la masse (état 01)
#define HIGH_1_LOW_0 1 // sortie en PWM (état 10)
#define HIGH_0_LOW_0 2 // sortie non connectée (état 00)

uint8_t sequence_A[6] = {1,1,2,0,0,2};
uint8_t sequence_B[6] = {0,2,1,1,2,0};
uint8_t sequence_C[6] = {2,0,0,2,1,1};

uint8_t is = 0;



void etat_bras_A(uint8_t etat) {
  switch (etat) {
    case HIGH_0_LOW_1:
      TIM2->CCR3 = 0;
      TIM2->CCR4 = 0;
      break;
    case HIGH_1_LOW_0:
      TIM2->CCR3 = timer2->ccr3;
      TIM2->CCR4 = timer2->ccr4;
      break;
    case HIGH_0_LOW_0:
      TIM2->CCR3 = 0;
      TIM2->CCR4 = timer2->arr + 1;
      break;
  }
}

void etat_bras_B(uint8_t etat) {
  switch (etat) {
    case HIGH_0_LOW_1:
      TIM3->CCR1 = 0;
      TIM3->CCR2 = 0;
      break;
    case HIGH_1_LOW_0:
      TIM3->CCR1 = timer3->ccr1;
      TIM3->CCR2 = timer3->ccr2;
      break;
    case HIGH_0_LOW_0:
      TIM3->CCR1 = 0;
      TIM3->CCR2 = timer2->arr + 1;
      break;
  }
}

void etat_bras_C(uint8_t etat) {
  switch (etat) {
    case HIGH_0_LOW_1:
      TIM4->CCR3 = 0;
      TIM4->CCR4 = 0;
      break;
    case HIGH_1_LOW_0:
      TIM4->CCR3 = timer4->ccr3;
      TIM4->CCR4 = timer4->ccr4;
      break;
    case HIGH_0_LOW_0:
      TIM4->CCR3 = 0;
      TIM4->CCR4 = timer4->arr + 1;
      break;
  }
}

void setup() {
  pinMode(INA_1,OUTPUT);
  digitalWrite(INA_1,LOW);
  pinMode(INA_2,OUTPUT);
  digitalWrite(INA_2,LOW);
  pinMode(INB_1,OUTPUT);
  digitalWrite(INB_1,LOW);
  pinMode(INB_2,OUTPUT);
  digitalWrite(INB_2,LOW);
  pinMode(INC_1,OUTPUT);
  digitalWrite(INC_1,LOW);
  pinMode(INC_2,OUTPUT);
  digitalWrite(INC_2,LOW);
  Serial.begin(115200);
  float r = 0.5;
  float temps_mort = 0.005; // 1/200 de période = 0,5 microsecondes
  float duty[2];
  duty[0] = r-temps_mort;
  duty[1] = r+temps_mort;
  bool invert[2] = {false,true};
  timer2 = new TimerPWM(2,2,periode_pwm,duty,invert,false,true,0);
  timer3 = new TimerPWM(3,2,periode_pwm,duty,invert,false,true,0);
  timer4 = new TimerPWM(4,2,periode_pwm,duty,invert,false,true,0);
  timer2->start();
  timer3->start();
  timer4->start();
}

void loop() {
  delay(1);
  etat_bras_A(sequence_A[is]);
  etat_bras_B(sequence_B[is]);
  etat_bras_C(sequence_C[is]);
  is += 1;
  if (is==6) is=0;
}

                     

La fonction etat_bras_A modifie l'état du bras A, qui est piloté par TIM2. Les deux sorties du timer utilisées sont CH3 et CH4 et les valeurs de CCR mémorisées dans timer2 sont nommées ccr3 et ccr4. Le rapport cyclique appliqué à une sortie d'un timer est légèrement différent de celui appliqué à l'autre sortie ce qui permet d'obtenir un temps mort entre le front descendant de l'une et le front montant de l'autre. Le temps mort le plus petit est déterminé par la valeur de ARR. Pour une période de 100 microsecondes, on a PSC=0 et ARR=12000-1 donc la résolution du rapport cyclique est 8,310-5 et ceci est la valeur la plus petite que l'on peut attribuer à temps_mort. La valeur de temps_mort définit le temps mort en fraction de la période.

Voici les signaux sur les 6 sorties pour une période de PWM de 100 microsecondes, avec un changement d'état toute les millisecondes :

[t,u1,u2,u3,u4,u5,u6] = np.loadtxt("timer8-100us-6etats.txt",unpack=True,skiprows=1)
figure(figsize=(16,12))
subplot(611)
plot(t*1e3,u1,label="TIM2_CH3(D3)")
grid()
legend(loc='upper right')
subplot(612)
plot(t*1e3,u2,label="TIM2_CH4(D2)",)
legend(loc='upper right')
grid()
subplot(613)
plot(t*1e3,u3,label="TIM3_CH1(D7)")
grid()
legend(loc='upper right')
subplot(614)
plot(t*1e3,u4,label="TIM3_CH2(D5)")
legend(loc='upper right')
grid()
subplot(615)
plot(t*1e3,u5,label="TIM4_CH3(D8)")
grid()
legend(loc='upper right')
subplot(616)
plot(t*1e3,u6,label="TIM4_CH4(D9)")
legend(loc='upper right')
grid()
xlabel("t (ms)",fontsize=16)
                
fig17fig17.pdf

On obtient bien un cycle à 6 états. Vérifions par exemple l'état entre les instants -3 et -2 ms, qui correspond au schéma du pont donné plus haut. La sortie A est à la masse car le transistor du haut (commandé par TIM2_CH3(D3)) est ouvert alors que celui du bas (commandé par TIM2_CH4(D2)) est fermé. Les deux transistors du bras B sont alternativement ouverts et fermés en opposition, ce qui permet d'appliquer à la sortie B une tension effective égale à Vs multipliée par le rapport cyclique. Les deux transistors du bras C sont ouverts donc le point C est à un potentiel non imposé par le pont.

Voici un détail des deux sorties qui délivrent un signal PWM :

[t,u1,u2,u3,u4,u5,u6] = np.loadtxt("timer8-100us-6etats-detail.txt",unpack=True,skiprows=1)
figure(figsize=(16,8))
plot(t*1e6,u1,label="TIM2_CH3(D3)")
plot(t*1e6,u2,label="TIM2_CH4(D2)",)
legend(loc='upper right')
grid()
xlabel("t (us)",fontsize=16)
                
fig18fig18.pdf

Ce détail permet de voir le temps mort : sa durée est de 0,5 microsecondes, comme prévu.

4.e. Modulation de fréquence

Afin d'effectuer une modulation FSK, on utilise le timer 2. Dans la fonction de traitement de l'interruption update, on incrémente un compteur de cycles. Avec une période de FSK_PERIOD cycles, on modifie la période du timer. Les deux périodes sont définies dans le tableau periode_pwm (en microsecondes).

modulationFSK.ino
#include "timerpwm.h"
TimerPWM *timer2;
uint32_t compteur;
double periode_pwm[2] = {100,110};
uint8_t ip;
#define FSK_PERIOD 10


extern "C" void TIM2_IRQHandler() {
  TIM2->SR &= ~1;
  compteur++;
  if (compteur==FSK_PERIOD) {
    compteur = 0;
    if (ip) ip=0; else ip=1;
    timer2->setPeriod(periode_pwm[ip]);
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(13,OUTPUT);
  while (!Serial);
  float duty[1] = {0.5};
  bool invert[3] = {false};
  compteur = 0;
  ip = 0;
  timer2 = new TimerPWM(2,1,periode_pwm[0],duty,invert,true,false,0,true);
  timer2->start();
  
}

void loop() {

}
           
                 

Le signal sur D3 est enregistré avec un oscilloscope 8 bits (PicoScope 3204B) sous la forme de 64 fenêtres (frames) de 62504 échantillons chacune, soit un total de 4 M échantillons. Le signal est stocké sous forme d'image PNG (voir Stockage et visualisation de signaux). Le fichier PNG fait 423 ko donc le taux de compression est de 0,11 (compression sans pertes).

Creative Commons LicenseTextes et figures sont mis à disposition sous contrat Creative Commons.