CH32V003 : timer et PWM

Ce que nous allons faire - Prérequis

Nous allons voir dans ce cours comment utiliser le timer pour moduler la largeur d'une impulsion afin de faire varier la luminosité d'une LED.

Les prérequis de ce cours sont :

  • Avoir suivi le cours CH32V003 : timer et interruptions.

  • Bien que non indispensable, il est recommandé d'avoir aussi suivi le cours CH32V003 : mesure de durées car il approfondit la gestion des interruptions et les différences entre TIM1 et TIM2.

  • Disposer d'une LED rouge, une LED verte, d'une résistance de 1kΩ (pour la LED rouge), d'une de 10kΩ (pour la LED verte), et d'un bouton poussoir.

Principe de la PWM

Le principe de la PWM (Pulse Width Modulation), encore appelée MLI en français (Modulation de Largeur d'Impulsion), est de produire un signal périodique et d'en moduler le rapport cyclique. L'intérêt de l'opération est que beaucoup de systèmes vont moyenner les impulsions reçues. En contrôlant le rapport cyclique, on contrôle donc la valeur moyenne de la réponse du système.

Dans le cas de qui nous occupe, une LED va transformer les impulsions électriques produites par le micro-contrôleur en impulsions lumineuses qui seront perçues par notre œil, et c'est ce dernier qui va en faire la moyenne. Ainsi, plus le rapport cyclique sera élevé, plus notre œil percevra la LED comme étant lumineuse.

Techniquement, le compteur de notre timer va faire son travail en comptant en permanence de 0 à arr. La valeur du compteur est comparée à ccr, une valeur stockée dans un registre ad hoc. Si la valeur du compteur est inférieure à ccr, la sortie est haute. Si la valeur du compteur est supérieure ou égale à ccr, la sortie est basse. C'est ce que montre la figure ci-dessous. On y voit qu'en modifiant la valeur de ccr, on modifie le rapport cyclique.

C'est le principe de base, mais le timer du CH32V003 offre plus de possibilités de comparaison. Il permet de choisir l'état initial de la sortie, haut (high) ou bas (low), ce que le manuel de référence appelle la "polarité". Il permet aussi de choisir comment le résultat de la comparaison affecte la sortie, ce que le manuel de référence appelle le "mode PWM" (1 ou 2).

La figure ci-dessous décrit les différentes possibilités. On y voit que dans la figure précédente, on avait configuré le mode PWM 1 et une polarité "high". On remarque également que si on change à la fois le mode PWM et la polarité, on obtient exactement la même chose que dans la situation de départ.

Donc en résumé :

  • La valeur du prescaler et celle de l'auto reload register déterminent la fréquence du signal.

  • Le rapport cyclique du signal est égal à la valeur du registre de comparaison divisée par celle de l'auto reload register.

  • En modifiant soit la polarité, soit le mode PWM, on peut inverser le signal de sortie.

Générer un signal de rapport cyclique fixe

Dans un premier temps, on va générer un signal de 100Hz avec un rapport cyclique fixe, par exemple 10%. Cette étape vous permettra de bien voir comment on configure un canal du timer en mode PWM.

Le moment est venu pour vous de créer un nouveau projet, modifier system_ch32v00x.c, puis remplacer le contenu du fichier main.c par le code suivant :

#include <ch32v00x.h>

#define LED_GPIO_PIN GPIO_Pin_4
#define LED_GPIO_PORT GPIOC
#define LED_GPIO_RCC RCC_APB2Periph_GPIOC

int main() {
	RCC_APB2PeriphClockCmd(LED_GPIO_RCC, ENABLE);

	GPIO_InitTypeDef gpioInit = { 0 };
	gpioInit.GPIO_Pin = LED_GPIO_PIN;
	// ATTENTION au mode ! Le timer est une "alternate function" du GPIO, 
	// donc on doit utiliser GPIO_Mode_AF_PP au lieu de GPIO_Mode_Out_PP.
	gpioInit.GPIO_Mode = GPIO_Mode_AF_PP;
	gpioInit.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(LED_GPIO_PORT, &gpioInit);

	// On utilise TIM1 car la LED est connectée à PC4 et la data sheet nous
	// indique que c'est le canal 4 de TIM1 qui arrive sur cette broche.
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_TIM1, ENABLE);

	TIM_TimeBaseInitTypeDef timeBaseInit = { 0 };
	// On veut une fréquence du signal de sortie de 100Hz, donc il faut diviser
	// l'horloge principale par 48000000 / 100 (compteur) / 100 (Hz) = 4800
	timeBaseInit.TIM_Prescaler = 4799;
	// On veut pouvoir indiquer le rapport cyclique directement en %,
	// donc le compteur devra compter de 0 à 99.
	timeBaseInit.TIM_Period = 99;
	timeBaseInit.TIM_ClockDivision = TIM_CKD_DIV1;
	timeBaseInit.TIM_CounterMode = TIM_CounterMode_Up;
	TIM_TimeBaseInit(TIM1, &timeBaseInit);

	TIM_OCInitTypeDef ocInit = { 0 };
	// On choisit le mode PWM 1
	ocInit.TIM_OCMode = TIM_OCMode_PWM1;
	// et la polarité "high".
	ocInit.TIM_OCPolarity = TIM_OCPolarity_High;
	// On active la sortie du canal.
	ocInit.TIM_OutputState = TIM_OutputState_Enable;
	// On veut un rapport cyclique de 10%
	ocInit.TIM_Pulse = 10;
	TIM_OC4Init(TIM1, &ocInit);

	TIM_CtrlPWMOutputs(TIM1, ENABLE);

	TIM_Cmd(TIM1, ENABLE);

	while (1);
}

Comme d'habitude, prenez le temps de lire les commentaires et de bien suivre le fonctionnement du code au debugger ou avec des printf() et de lire les passages correspondants du manuel de référence.

Essayez aussi différentes valeurs de rapport cyclique (ex. 10, 50 et 90%) pour voir leur influence sur la luminosité de la LED. Il vous faudra pour l'instant recompiler et reflasher à chaque fois.

Faire varier le rapport cyclique

On veut voir la luminosité de notre LED croître et décroître puis recommencer, le tout en douceur. Nous avons besoin pour cela d'un cadencement assez lent pour que notre œil ait le temps de percevoir les changements. On va donc faire varier le rapport cyclique de 10% tous les 1/10ème de seconde.

Or, il se trouve que notre timer génère un signal de 100Hz, c'est-à-dire que le compteur du timer déborde 100 fois par seconde. Il nous suffit donc de compter ses débordements et de déclencher un changement de rapport cyclique tous les 10 débordements et le tour est joué. Les timers étant en nombre limité, il ne faut pas hésiter à recourir à cette astuce à chaque fois que c'est possible.

#include <ch32v00x.h>
#include <stdbool.h>

#define LED_GPIO_PIN GPIO_Pin_4
#define LED_GPIO_PORT GPIOC
#define LED_GPIO_RCC RCC_APB2Periph_GPIOC

// On fera varier le rapport cyclique par pas de 5%.
#define DUTY_CYCLE_INCREMENT 5

// On veut changer le rapport cyclique tous les 10 débordements du 
// compteur principal, ce qui pour une fréquence de 100Hz revient 
// à modifier la luminosité de la LED 10 fois par seconde.
#define COUNT_DOWN 10
volatile int countDown = COUNT_DOWN;

__attribute__((interrupt("WCH-Interrupt-fast")))
void TIM1_UP_IRQHandler() {
	if (TIM_GetITStatus(TIM1, TIM_IT_Update) != RESET) {
		countDown--;
		TIM_ClearITPendingBit(TIM1, TIM_IT_Update);
	}
}

int main() {
	RCC_APB2PeriphClockCmd(LED_GPIO_RCC, ENABLE);

	GPIO_InitTypeDef gpioInit = { 0 };
	gpioInit.GPIO_Pin = LED_GPIO_PIN;
	// ATTENTION au mode !
	gpioInit.GPIO_Mode = GPIO_Mode_AF_PP;
	gpioInit.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(LED_GPIO_PORT, &gpioInit);

	RCC_APB2PeriphClockCmd(RCC_APB2Periph_TIM1, ENABLE);

	TIM_TimeBaseInitTypeDef timeBaseInit = { 0 };
	timeBaseInit.TIM_Period = 99;
	timeBaseInit.TIM_Prescaler = 4799;
	timeBaseInit.TIM_ClockDivision = TIM_CKD_DIV1;
	timeBaseInit.TIM_CounterMode = TIM_CounterMode_Up;
	TIM_TimeBaseInit(TIM1, &timeBaseInit);

	// On a besoin d'une variable pour le rapport cyclique
	uint16_t dutyCycle = 0;
	// et d'une autre pour le sens de variation (croissant/décroissant).
	bool increase = true;

	TIM_OCInitTypeDef ocInit = { 0 };
	ocInit.TIM_OCMode = TIM_OCMode_PWM1;
	ocInit.TIM_OCPolarity = TIM_OCPolarity_High;
	ocInit.TIM_OutputState = TIM_OutputState_Enable;
	ocInit.TIM_Pulse = dutyCycle;
	TIM_OC4Init(TIM1, &ocInit);

	TIM_CtrlPWMOutputs(TIM1, ENABLE);

	// On active les interruptions de mise à jour pour
	// être notifié de chaque débordement du compteur.
	NVIC_InitTypeDef nvicInit = { 0 };
	nvicInit.NVIC_IRQChannel = TIM1_UP_IRQn;
	nvicInit.NVIC_IRQChannelPreemptionPriority = 0;
	nvicInit.NVIC_IRQChannelSubPriority = 1;
	nvicInit.NVIC_IRQChannelCmd = ENABLE;
	NVIC_Init(&nvicInit);

	TIM_ITConfig(TIM1, TIM_IT_Update , ENABLE);

	TIM_Cmd(TIM1, ENABLE);

	while (1) {
		if (countDown == 0) {
			countDown = COUNT_DOWN;

			// On regarde si on doit changer de sens de variation.
			if (increase) {
				if (dutyCycle > (100 - DUTY_CYCLE_INCREMENT)) {
					increase = false;
				}
			} else {
				if (dutyCycle < DUTY_CYCLE_INCREMENT) {
					increase = true;
				}
			}

			// On calcule le nouveau rapport cyclique
			dutyCycle = increase
				? (dutyCycle + DUTY_CYCLE_INCREMENT)
				: (dutyCycle - DUTY_CYCLE_INCREMENT);

			// On met à jour le registre de comparaison du canal 4.
			TIM_SetCompare4(TIM1, dutyCycle);
		}
	}
}

Vous pourrez constater que la relation entre rapport cyclique et luminosité n'est pas linéaire, mais ça reste acceptable dans notre cas.

Forcer l'état d'une sortie PWM

Il peut parfois être nécessaire de forcer temporairement une sortie PWM à l'état haut ou à l'état bas. Deux valeurs de TIM_OCMode sont dédiée à cet usage, 0x50 pour forcer à l'état haut et 0x40 pour l'état bas.

WCH n'a défini aucune macro pour représenter ces valeurs, mais elles figurent bien dans le manuel de référence. Vous trouverez ci-dessous un extrait de code montrant comment l'utiliser :

// Vous pouvez définir des constantes par souci de clarté.
#define TIM_OCMode_ForceLow  0x40
#define TIM_OCMode_ForceHigh 0x50

// Cette fonction permet d'activer la sortie PWM avec un rapport
// cyclique fixe ou de la désactiver en la forçant à l'état haut.
void enablePwmOutput(bool enable) {
	TIM_OCInitTypeDef ocInit = { 0 };
	ocInit.TIM_OCMode = enable ? TIM_OCMode_PWM1 : TIM_OCMode_ForceHigh;
	ocInit.TIM_OCPolarity = TIM_OCPolarity_High;
	ocInit.TIM_OutputState = TIM_OutputState_Enable;
	ocInit.TIM_Pulse = DUTY_CYCLE_VALUE;
	TIM_OC2Init(TIM1, &ocInit);
}

Les sorties complémentaires de TIM1 et la fonction "brake"

Les 3 premiers canaux de TIM1 sont équipés de 2 sorties. Celle appelée T1CHx (ex. T1CH1) est la sortie "positive" et fonctionne exactement de la même façon que T1CH4 ou que les sorties des canaux de TIM2. Celle appelée T1CHxN (ex. T1CH1N) est dans l'état inverse de T1CHx. Par exemple, quand T1CH1 est à l'état haut, T1CH1N est à l'état bas. Pour cette raison, T1CHxN est appelée "sortie complémentaire" de T1CHx.

De plus, TIM1 permet de configurer un "temps mort" inséré lors de chaque changement d'état des sorties, comme dans la figure suivante :

Ceci est rendu nécessaire par le fait que les canaux à sorties complémentaires sont généralement utilisé pour commander des circuits de puissance et que le temps nécessaire à ces circuits pour changer d'état n'est pas négligeable. Il existe donc un risque que 2 circuits se retrouvent au même état à un moment donné, avec des conséquences destructrices. Le temps mort permet de laisser le temps à ces circuits de changer d'état en toute sécurité. La configuration du temps mort s'applique à l'ensemble des 3 canaux concernés.

Il est également possible de configurer TIM1 pour accepter sur l'entrée T1BKIN un signal d'arrêt d'urgence, par exemple en cas de détection d'anomalie de fonctionnement. C'est la fonction "brake" du timer et son rôle est de mettre immédiatement les sorties de tous les canaux PWM dans un état de sûreté. Cet état dépend de votre application et est donc configurable pour chaque sortie, autant les sorties "positives" que les sorties complémentaires.

Notez que la fréquence d'horloge du générateur de temps mort est fDTS, c'est-à-dire la même que celle des filtres d'entrée des canaux de capture et de l'entrée de déclenchement externe. La durée des temps morts est donc affectée par la valeur de TIM_ClockDivision.

Nous allons voir comment utiliser les sorties complémentaires en faisant "palpiter" une LED sur chaque sortie. Un bouton poussoir nous permettra de simuler la fonction "brake". Réalisez donc le câblage suivant sur votre breadboard et reliez-la à votre carte de développement.

Créez maintenant un nouveau projet, modifiez system_ch32v00x.c, puis remplacer le contenu du fichier main.c par le code ci-dessous. Il est basé sur le précédent, donc vous devriez trouver facilement vos marques.

#include <ch32v00x.h>
#include <stdbool.h>

// On a 2 LED sur le même port au lieu d'une seule.
#define LED_GPIO_PIN_RED GPIO_Pin_0
#define LED_GPIO_PIN_GREEN GPIO_Pin_2
#define LED_GPIO_PORT GPIOD
#define LED_GPIO_RCC RCC_APB2Periph_GPIOD

#define DUTY_CYCLE_INCREMENT 5

#define COUNT_DOWN 10
volatile int countDown = COUNT_DOWN;

__attribute__((interrupt("WCH-Interrupt-fast")))
void TIM1_UP_IRQHandler() {
	if (TIM_GetITStatus(TIM1, TIM_IT_Update) != RESET) {
		countDown--;
		TIM_ClearITPendingBit(TIM1, TIM_IT_Update);
	}
}

int main() {
	// On active le port sur lequel sont reliées les 2 LED.
	RCC_APB2PeriphClockCmd(LED_GPIO_RCC, ENABLE);

	// On initialise les lignes de GPIO des LED.
	GPIO_InitTypeDef gpioInit = { 0 };
	gpioInit.GPIO_Pin = LED_GPIO_PIN_RED | LED_GPIO_PIN_GREEN;
	gpioInit.GPIO_Mode = GPIO_Mode_AF_PP;
	gpioInit.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(LED_GPIO_PORT, &gpioInit);

	// On active le port de l'entrée "brake" T1BKIN.
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE);
	
	// On configure l'entrée "break" T1BKIN (PC2).
	gpioInit.GPIO_Pin = GPIO_Pin_2;
	gpioInit.GPIO_Mode = GPIO_Mode_IPU;
	GPIO_Init(GPIOC, &gpioInit);

	RCC_APB2PeriphClockCmd(RCC_APB2Periph_TIM1, ENABLE);

	TIM_TimeBaseInitTypeDef timeBaseInit = { 0 };
	timeBaseInit.TIM_Period = 99;
	timeBaseInit.TIM_Prescaler = 4799;
	timeBaseInit.TIM_ClockDivision = TIM_CKD_DIV4;
	timeBaseInit.TIM_CounterMode = TIM_CounterMode_Up;
	TIM_TimeBaseInit(TIM1, &timeBaseInit);

	uint16_t dutyCycle = 0;
	bool increase = true;

	TIM_OCInitTypeDef ocInit = { 0 };
	ocInit.TIM_Pulse = dutyCycle;
	ocInit.TIM_OCMode = TIM_OCMode_PWM1;

	// -- On configure la sortie "positive" (T1CH1).
	ocInit.TIM_OCPolarity = TIM_OCPolarity_High;
	ocInit.TIM_OutputState = TIM_OutputState_Enable;
	// En cas de "brake", on veut que la sortie soit à l'état logique bas.
	ocInit.TIM_OCIdleState = TIM_OCIdleState_Reset;

	// -- On configure la sortie complémentaire (T1CH1N).
	// La polarité doit être la même que celle de T1CH1,
	// sans quoi les 2 sorties seraient identiques.
	ocInit.TIM_OCNPolarity = TIM_OCPolarity_High;
	ocInit.TIM_OutputNState = TIM_OutputNState_Enable;
	// En cas de "break", on veut que la sortie soit à l'état logique haut.
	ocInit.TIM_OCNIdleState = TIM_OCNIdleState_Set;
	// Remarque : la combinaison TIM_OCIdleState_Set + TIM_OCNIdleState_Set
	// n'est pas valide, elle est traitée comme si on avait configuré
	// TIM_OCIdleState_Reset + TIM_OCNIdleState_Reset

	// Canal 1 au lieu de 4
	TIM_OC1Init(TIM1, &ocInit);

	TIM_BDTRInitTypeDef bdtrInit = { 0 };

	// -- On configure la durée du temps mort (voir le manuel de
	// référence pour la signification de la valeur (section
	// 10.4.18 "Brake and Deadtime Register (TIM1_BDTR)").
	// Remarque : dans notre cas précis, on ne verra aucune différence
	// à l'oeil nu, on pourrait aussi bien ne pas le configurer, cette
	// ligne est juste là pour l'exemple.
	bdtrInit.TIM_DeadTime = 0xff;

	// -- On configure la fonction "break"
	// On active la fonction "break".
	bdtrInit.TIM_Break = TIM_Break_Enable;
	// Quand on appuie sur le poussoir, il envoie un niveau logique bas.
	bdtrInit.TIM_BreakPolarity = TIM_BreakPolarity_Low;

	TIM_BDTRConfig(TIM1, &bdtrInit);

	TIM_CtrlPWMOutputs(TIM1, ENABLE);

	NVIC_InitTypeDef nvicInit = { 0 };
	nvicInit.NVIC_IRQChannel = TIM1_UP_IRQn;
	nvicInit.NVIC_IRQChannelPreemptionPriority = 0;
	nvicInit.NVIC_IRQChannelSubPriority = 1;
	nvicInit.NVIC_IRQChannelCmd = ENABLE;
	NVIC_Init(&nvicInit);

	TIM_ITConfig(TIM1, TIM_IT_Update , ENABLE);

	TIM_Cmd(TIM1, ENABLE);

	while (1) {
		if (countDown == 0) {
			countDown = COUNT_DOWN;

			if (increase) {
				if (dutyCycle > (100 - DUTY_CYCLE_INCREMENT)) {
					increase = false;
				}
			} else {
				if (dutyCycle < DUTY_CYCLE_INCREMENT) {
					increase = true;
				}
			}

			dutyCycle = increase
				? (dutyCycle + DUTY_CYCLE_INCREMENT)
				: (dutyCycle - DUTY_CYCLE_INCREMENT);

			// Canal 1 au lieu de 4
			TIM_SetCompare1(TIM1, dutyCycle);
		}
	}
}

Vous pouvez maintenant voir vos 2 LED palpiter alternativement. Lorsque vous appuyez sur le poussoir, les 2 LED sont mises dans l'état que nous avons choisi, à savoir éteinte pour la verte et allumée pour la rouge, et y resteront jusqu'à ce que vous appuyez sur le bouton NRST de la carte de développement, ou que vous la mettiez hors tension puis de nouveau en marche.

Applications de la PWM

La PWM trouve des applications dans beaucoup de situations, par exemple :

  • variation de la vitesse d'un moteur

  • contrôle de la température d'un dispositif de chauffe

  • contrôle de la luminosité d'un écran ou d'un dispositif d'éclairage

  • contrôle de la position angulaire d'un servo (ou du sens et de la vitesse de rotation pour les servos 360°)

  • conversion numérique/analogique

  • contrôle du volume d'un buzzer

  • contrôle de la tension de sortie d'une alimentation à découpage

  • transmission de données

Ce que nous avons appris

Vous savez maintenant :

  • Ce qu'est la PWM et à quoi elle sert.

  • Configurer un canal du timer en mode PWM.

  • Faire varier son rapport cyclique.

  • Utiliser les sorties complémentaires, le temps mort et la fonction "break".


Copyright (c) 2024 Vincent DEFERT - Tous droits réservés