CH32V003 : ADC, joystick et DMA

Ce que nous allons faire - Prérequis

Nous allons aborder l'utilisation de l'ADC du CH32V003. C'est un périphérique riche et flexible, donc forcément complexe. Nous allons donc l'étudier en plusieurs étapes, en changeant une seule chose à la fois pour que vous puissiez bien voir le rôle de chaque élément.

Les prérequis de ce cours sont :

Qu'est-ce que c'est ? Comment ça marche ?

ADC signifie "Analogue-to-Digital Converter", ou convertisseur analogique-numérique en français. Ce périphérique sert à convertir une tension en un nombre manipulable par le micro-contrôleur, cette tension représentant généralement une grandeur physique (ex. température, luminosité).

Vous vous souvenez du DAC, que nous avons vu dans un cours précédent et qui en est la fonctionnalité inverse. Comme le DAC, l'ADC a une résolution qui correspond au nombre de bits du résultat de la conversion.

Une manière classique de réaliser un ADC consiste à utiliser la technique des approximations successives, dans laquelle on compare la tension à convertir à la sortie d'un DAC dont on fait varier l'entrée.

Au départ, on met le bit de poids le plus fort (MSB) à 1 et tous les autres à 0. Si la tension de sortie du DAC est toujours inférieure à la tension à convertir, on garde ce 1, sinon on le remet à 0.

On passe ensuite au bit de rang immédiatement inférieur qu'on met à son tour à 1. Si la tension de sortie du DAC est toujours inférieure à la tension à convertir, on garde ce 1, sinon on le remet à 0.

On répète l'opération jusqu'à ce qu'on ait traité le bit de poids le plus faible (LSB), ce qui nous donne le résultat final. On remarquera que ce résultat est forcément une approximation, dont la finesse dépend de la résolution de l'ADC. On notera également que la conversion n'est pas instantanée, ce qui aura un impact sur notre code.

Pour savoir à quelle tension correspond le nombre issu de la conversion, il faut connaître la valeur de la tension de référence de l'ADC, c'est-à-dire la tension qui alimente le réseau de résistances du DAC interne utilisé pour les approximations successives.

On trouve couramment dans les micro-contrôleurs des ADC d'une résolution de 10 ou 12 bits, parfois 16. Si on a besoin d'un ADC de grande précision (ex. résolution de 24 bits pour des applications de métrologie), on utilisera un ADC externe.

Si on utilise un ADC de 10 bits, la valeur représentant la tension à convertir pourra varier entre 0 et 1023 (bornes incluses). Si c'est un ADC de 12 bits, ce sera entre 0 et 4095.

Beaucoup de micro-contrôleurs offrent la possibilité d'utiliser une référence de tension externe. Si on n'a pas besoin d'une grande précision, on peut utiliser la tension d'alimentation du micro-contrôleur.

La notion de référence de tension implique que la tension à convertir ne devra pas la dépasser. La notion de résolution implique que la tension maximum à convertir soit supérieure à la valeur représentée par le LSB.

En pratique, l'entrée de l'ADC sera précédée d'un circuit de conditionnement du signal pour s'assurer que le signal d'entrée évolue dans des limites compatibles avec sa bonne conversion. On peut donc utiliser un amplificateur si le signal est trop faible, ou un diviseur de tension s'il est trop fort, par exemple. Certains micro-contrôleurs intègrent un amplificateur à gain programmable (PGA, Programmable Gain Amplifier en anglais) justement dans ce but.

Un ADC étant un périphérique complexe, il n'y en a pas beaucoup dans un micro-contrôleur, généralement un ou deux. On peut cependant avoir bien plus de grandeurs à mesurer. La solution est de partager un même ADC entre plusieurs canaux de mesure, généralement 8 à 15 canaux par ADC.

Votre programme doit donc sélectionner le canal à traiter avant de lancer la conversion. La tension présente sur la broche d'entrée du canal est transférée dans un condensateur interne à l'ADC qui la maintiendra le temps de la conversion. On appelle ce dispositif "sample and hold" (échantillonner et conserver). Cette précaution permet de mesurer la tension présente en entrée à l'instant précis de l'échantillonnage sans que la mesure soit faussée par les variations ultérieures sur l'entrée.

Particularités de l'ADC du CH32V003

Les parties importantes de la documentation concernant l'ADC sont la section 3.3.14 "10-bit ADC characteristics" de la data sheet et le chapitre 9 "Analog-to-digital Converter (ADC)" du manuel de référence.

L'ADC du CH32V003 a une résolution de 10 bits et dispose de 8 canaux externes (numérotés de 0 à 7) et 2 internes (l'un pour la tension de référence, l'autre pour la calibration). Ce sont évidemment les canaux externes qui nous intéressent. La tension de référence est obligatoirement la tension d'alimentation.

Il existe un mode de configuration spécifique des lignes de GPIO pour l'ADC, GPIO_Mode_AIN. Lorsqu'une ligne est placée dans ce mode, la broche correspondante est reliée directement au canal de l'ADC.

Les conversions peuvent être traitées en mode polling (on teste l'indicateur de fin de conversion jusqu'à ce qu'elle soit terminée), en mode interruption (une interruption est déclenchée en fin de conversion), ou en mode DMA (nous en parlerons plus longuement plus loin dans ce cours).

L'ADC peut utiliser les canaux de 2 façons. On parle de canaux "ordinaires" ("regular" en anglais, ou "rule" dans la traduction incorrecte de WCH) et de canaux "injectés" ("injected" en anglais). C'est un abus de langage parce que ça se rapporte à la manière d'utiliser les canaux et pas à leur nature, qui elle ne change pas. Comme c'est l'usage, je reproduirai ici le même abus de langage, mais soyez en bien conscient.

Canaux ordinaires

L'ADC permet de configurer une liste pouvant comporter jusqu'à 16 canaux à traiter en mode ordinaire. Cette limite vient de la réutilisation dans le CH32V003 d'un ADC également utilisé dans des micro-contrôleurs plus puissants de la gamme WCH, ça ne change pas le fait qu'on n'ait que 8 canaux physiques.

Lorsque la conversion d'un canal est terminée, le résultat est placé dans le registre ADC_RDATAR. Comme il n'y a qu'un registre ADC_RDATAR, le résultat de la conversion d'un canal de la liste écrasera le précédent. Lorsqu'on utilise plus d'un canal ordinaire, on traite donc les conversions en mode DMA et on retrouve les résultats directement dans un tableau en mémoire.

Canaux injectés

L'ADC permet de configurer une liste pouvant comporter jusqu'à 4 canaux à traiter en mode injecté. Le résultat de la conversion est placé dans un registre différent pour chaque canal, les registres ADC_IDATAR1 à ADC_IDATAR4.

On recourra donc au traitement en mode injecté dans le cas où on souhaite utiliser le polling ou les interruptions. Notez qu'on peut tout à fait combiner canaux ordinaires et injectés et nous en verrons un exemple.

Calibrage

Lors de la fabrication des composants, il y a une marge d'erreur sur leur valeur et c'est aussi vrai pour ceux qu'il y a dans un micro-contrôleur. De plus, la température et la tension de service influent aussi sur leur valeur. Pour avoir un résultat optimal lors de la conversion, on procède donc à un calibrage de l'ADC avant de l'utiliser afin de compenser ces perturbations.

Le module joystick

Le module joystick est formé de 2 potentiomètres montés à 90° l'un de l'autre, et d'un bouton poussoir qu'on peut actionner en appuyant sur la poignée de commande.

Les 2 potentiomètres forment des diviseurs de tension (un par axe), ce qui permet de déterminer une position X-Y en mesurant la tension au curseur de chaque potentiomètre. Le joystick est donc le composant idéal pour jouer avec un ADC !

Les bornes du module correspondant aux tensions de sortie sont repérées VRx et VRy sur la sérigraphie du circuit imprimé, mais ça n'est pas très explicite. La figure ci-dessous permet de bien voir à quoi ça correspond.

Électriquement, voici son schéma ainsi que la manière de le relier à la carte de développement :

Nous allons aborder différentes façons de d'utiliser l'ADC pour lire la position du joystick. Pour la visualiser, nous nous contenterons d'envoyer le résultat des conversions sur le terminal de MRS.

Canal ordinaire, conversion en mode polling

Le mode polling est la façon la plus simple d'aborder l'ADC, bien que ce soit sans doute aussi la moins utilisée. Pour que ce soit encore plus simple, nous ne traiterons qu'un seul canal.

Créez maintenant un nouveau projet, modifiez system_ch32v00x.c, puis remplacez le contenu du fichier main.c par le code suivant :

#include <ch32v00x.h>
#include <debug.h>

int main() {
	SystemCoreClockUpdate();
	Delay_Init();
	USART_Printf_Init(115200);

	// -- On initialise les lignes de GPIO des canaux de l'ADC.
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOD, ENABLE);
	GPIO_InitTypeDef gpioInit = { 0 };
	// VRy = PD2 = A3
	gpioInit.GPIO_Pin = GPIO_Pin_2;
	// Notez le mode utilisé.
	gpioInit.GPIO_Mode = GPIO_Mode_AIN;
	GPIO_Init(GPIOD, &gpioInit);

	// -- On met en service l'ADC.
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);
	
	// -- On configure l'horloge utilisée par l'ADC pour cadencer 
	// la conversion, soit ici 48MHz / 8 = 6MHz.
	RCC_ADCCLKConfig(RCC_PCLK2_Div8);

	// -- On configure la conversion.
	ADC_InitTypeDef  adcInit = { 0 };
	adcInit.ADC_Mode = ADC_Mode_Independent;
	// On n'utilise qu'un seul canal ordinaire...
	adcInit.ADC_NbrOfChannel = 1;
	// ...donc on n'a pas d'autres canaux à traiter.
	adcInit.ADC_ScanConvMode = DISABLE;
	// On veut déclencher nous-mêmes la conversion.
	adcInit.ADC_ContinuousConvMode = DISABLE;
	adcInit.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None;
	// Les 10 bits du résultat sont alignés à droite dans 
	// les 16 bits du registre correspondant.
	adcInit.ADC_DataAlign = ADC_DataAlign_Right;
	ADC_Init(ADC1, &adcInit);

	// -- On démarre l'ADC
	ADC_Cmd(ADC1, ENABLE);

	// -- On le calibre avant la première utilisation.
	ADC_ResetCalibration(ADC1);
	while (ADC_GetResetCalibrationStatus(ADC1));
	ADC_StartCalibration(ADC1);
	while (ADC_GetCalibrationStatus(ADC1));

	// -- On configure le canal qu'on va utiliser.
	ADC_RegularChannelConfig(ADC1, ADC_Channel_3, 1, ADC_SampleTime_15Cycles);

	while (1) {
		// On démarre la conversion.
		ADC_SoftwareStartConvCmd(ADC1, ENABLE);

		// On attend qu'elle soit terminée.
		while (!ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC));

		// On récupère le résultat...
		uint16_t val = ADC_GetConversionValue(ADC1);
		
		// ...et on l'affiche.
		printf("adc = %d\r\n", val);
		
		// On attend un peu pour que les printf() ne défilent pas trop vite.
		Delay_Ms(500);
	}
}

Canaux injectés, conversion en continu en mode interruptions

Si on voulait utiliser notre joystick dans un jeu, par exemple, nous aurions bien d'autres choses à faire que d'attendre la fin de la conversion. Ce qui nous arrangerait beaucoup, c'est que la conversion se fasse toute seule en continu et qu'on ait juste à lire les valeurs quelque part. C'est exactement ce que nous allons faire. Nous utiliserons pour l'instant des interruptions et, comme nous avons 2 canaux à convertir, ce seront des canaux injectés.

Remplacez le contenu du fichier main.c par le code suivant :

#include <ch32v00x.h>
#include <debug.h>

volatile uint16_t vrX;
volatile uint16_t vrY;

// Routine d'interruption de l'ADC
__attribute__((interrupt("WCH-Interrupt-fast")))
void ADC1_IRQHandler() {
	// On traite l'interruption de fin de conversion des canaux injectés.
	if (ADC_GetITStatus(ADC1, ADC_IT_JEOC)) {
		// On lit les valeurs associées à chaque canal dans les registres ADC_IDATARn.
		// ADC_InjectedChannel_1 correspond à l'ordre dans la liste des canaux injectés
		// et non au numéro du canal. Il faut bien faire attention à ce point.
		vrY = ADC_GetInjectedConversionValue(ADC1, ADC_InjectedChannel_1);
		vrX = ADC_GetInjectedConversionValue(ADC1, ADC_InjectedChannel_2);
		
		// On remet à zéro l'indicateur d'interruption.
		ADC_ClearITPendingBit(ADC1, ADC_IT_JEOC);
	}
}

int main() {
	Delay_Init();
	USART_Printf_Init(115200);

	// -- On configure les 2 lignes de GPIO correspondant à nos canaux.
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOD, ENABLE);
	GPIO_InitTypeDef gpioInit = { 0 };
	// VRy = PD2 = A3
	// VRx = PD3 = A4
	gpioInit.GPIO_Pin = GPIO_Pin_2 | GPIO_Pin_3;
	gpioInit.GPIO_Mode = GPIO_Mode_AIN;
	GPIO_Init(GPIOD, &gpioInit);

	// -- On configure le contrôleur d'interruptions.
	NVIC_InitTypeDef nvicInit = { 0 };
	nvicInit.NVIC_IRQChannel = ADC_IRQn;
	nvicInit.NVIC_IRQChannelPreemptionPriority = 0;
	nvicInit.NVIC_IRQChannelSubPriority = 1;
	nvicInit.NVIC_IRQChannelCmd = ENABLE;
	NVIC_Init(&nvicInit);

	// -- On met en service l'ADC.
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);
	
	// -- On configure l'horloge utilisée par l'ADC pour cadencer 
	// la conversion, soit ici 48MHz / 8 = 6MHz.
	RCC_ADCCLKConfig(RCC_PCLK2_Div8);

	// -- On configure la conversion.
	ADC_InitTypeDef  adcInit = { 0 };
	adcInit.ADC_Mode = ADC_Mode_Independent;
	// Convertit les canaux de la liste les uns après les autres.
	// Si on oublie ce point, seul le premier canal de la liste sera traité.
	adcInit.ADC_ScanConvMode = ENABLE;
	// On relance une nouvelle conversion dès que la dernière est terminée
	// Si on oublie ce point, une seule conversion sera effectuée, 
	// donc l'affichage ne changera pas quand on bouge le joystick.
	adcInit.ADC_ContinuousConvMode = ENABLE;
	adcInit.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None;
	adcInit.ADC_DataAlign = ADC_DataAlign_Right;
	adcInit.ADC_NbrOfChannel = 1;
	ADC_Init(ADC1, &adcInit);

	// On veut une interruption lorsque la conversion des canaux injectés est terminée.
	ADC_ITConfig(ADC1, ADC_IT_JEOC, ENABLE);

	// On définit les canaux injectés dans l'ordre où il seront traités.
	ADC_InjectedSequencerLengthConfig(ADC1, 2);
	ADC_InjectedChannelConfig(ADC1, ADC_Channel_3, 1, ADC_SampleTime_15Cycles);
	ADC_InjectedChannelConfig(ADC1, ADC_Channel_4, 2, ADC_SampleTime_15Cycles);

	// La conversion des canaux injectés démarrera par un "software start".
	ADC_ExternalTrigInjectedConvConfig(ADC1, ADC_ExternalTrigInjecConv_None);
	ADC_ExternalTrigInjectedConvCmd(ADC1, ENABLE);

	// -- On démarre l'ADC
	ADC_Cmd(ADC1, ENABLE);

	// -- On le calibre avant la première utilisation.
	ADC_ResetCalibration(ADC1);
	while (ADC_GetResetCalibrationStatus(ADC1));
	ADC_StartCalibration(ADC1);
	while (ADC_GetCalibrationStatus(ADC1));

	// -- On lance la première conversion des canaux injectés, les autres 
	// suivront automatiquement grâce à ADC_ContinuousConvMode = ENABLE.
	ADC_SoftwareStartInjectedConvCmd(ADC1, ENABLE);

	while (1) {
		// On affiche les dernières valeurs disponibles.
		printf("vrX = %d - vrY = %d\r\n", vrX, vrY);
		
		// On attend un peu pour que les printf() ne défilent pas trop vite.
		Delay_Ms(500);
	}
}

Canaux injectés, interruptions, déclenchement par timer

Dans beaucoup d'applications, le déclenchement en continu ne convient pas parce qu'on a besoin de connaître précisément l'intervalle de temps entre chaque conversion. On utilise alors un timer pour déclencher les conversions.

Remplacez le contenu du fichier main.c par le code suivant :

#include <ch32v00x.h>
#include <debug.h>

volatile uint16_t vrX;
volatile uint16_t vrY;

__attribute__((interrupt("WCH-Interrupt-fast")))
void ADC1_IRQHandler() {
	if (ADC_GetITStatus(ADC1, ADC_IT_JEOC)) {
		vrY = ADC_GetInjectedConversionValue(ADC1, ADC_InjectedChannel_1);
		vrX = ADC_GetInjectedConversionValue(ADC1, ADC_InjectedChannel_2);
		ADC_ClearITPendingBit(ADC1, ADC_IT_JEOC);
	}
}

int main() {
	Delay_Init();
	USART_Printf_Init(115200);

	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOD, ENABLE);
	GPIO_InitTypeDef gpioInit = { 0 };
	// VRy = PD2 = A3
	// VRx = PD3 = A4
	gpioInit.GPIO_Pin = GPIO_Pin_2 | GPIO_Pin_3;
	gpioInit.GPIO_Mode = GPIO_Mode_AIN;
	GPIO_Init(GPIOD, &gpioInit);

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

	RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);
	RCC_ADCCLKConfig(RCC_PCLK2_Div8);

	ADC_InitTypeDef  adcInit = { 0 };
	adcInit.ADC_Mode = ADC_Mode_Independent;
	// Convertit les canaux de la liste les uns après les autres.
	// Si on oublie ce point, seul le premier canal de la liste sera traité.
	adcInit.ADC_ScanConvMode = ENABLE;
	adcInit.ADC_ContinuousConvMode = DISABLE;
	// Ceci est le déclencheur de la conversion des canaux ADC ordinaires.
	// Comme nous utilisons des canaux injectés, ce réglage ne nous concerne 
	// pas, nous devrons utiliser ADC_ExternalTrigInjectedConvConfig().
	adcInit.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None;
	adcInit.ADC_DataAlign = ADC_DataAlign_Right;
	adcInit.ADC_NbrOfChannel = 1;
	ADC_Init(ADC1, &adcInit);

	ADC_ITConfig(ADC1, ADC_IT_JEOC, ENABLE);

	ADC_InjectedSequencerLengthConfig(ADC1, 2);
	ADC_InjectedChannelConfig(ADC1, ADC_Channel_3, 1, ADC_SampleTime_15Cycles);
	ADC_InjectedChannelConfig(ADC1, ADC_Channel_4, 2, ADC_SampleTime_15Cycles);

	// La conversion des canaux injectés est déclenchée par le canal PWM 3 de TIM2.
	// Pour les canaux injectés, on a le choix entre les canaux PWM 3 et 4 (de TIM 1 ou TIM2).
	// Pour les canaux ordinaires, on a le choix entre les canaux PWM 1 et 2 (du même timer).
	ADC_ExternalTrigInjectedConvConfig(ADC1, ADC_ExternalTrigInjecConv_T2_CC3);
	ADC_ExternalTrigInjectedConvCmd(ADC1, ENABLE);

	ADC_Cmd(ADC1, ENABLE);

	ADC_ResetCalibration(ADC1);
	while (ADC_GetResetCalibrationStatus(ADC1));
	ADC_StartCalibration(ADC1);
	while (ADC_GetCalibrationStatus(ADC1));

	// -- On met en service TIM2
	RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);

	// -- On configure la base de temps.
	TIM_TimeBaseInitTypeDef timeBaseInit = { 0 };
	// On veut un déclenchement tous les 1/10ème de seconde (par exemple).
	timeBaseInit.TIM_Period = 100 - 1;
	timeBaseInit.TIM_Prescaler = 48000 - 1;
	timeBaseInit.TIM_ClockDivision = TIM_CKD_DIV1;
	timeBaseInit.TIM_CounterMode = TIM_CounterMode_Up;
	TIM_TimeBaseInit(TIM2, &timeBaseInit);

	// -- On configure le canal 3 de TIM2 en mode PWM
	TIM_OCInitTypeDef ocInit = { 0 };
	ocInit.TIM_OCMode = TIM_OCMode_PWM1;
	// Ne pas oublier ce point. La sortie du canal PWM est utilisée pour 
	// déclencher l'ADC, même si le signal PWM n'est pas envoyé au GPIO.
	ocInit.TIM_OutputState = TIM_OutputState_Enable;
	// Un rapport cyclique de 50% fera l'affaire. 
	// Si on avait des canaux ordinaires à convertir en plus, la conversion 
	// lancée en premier serait celle ayant le rapport cyclique le plus faible.
	// Il faudrait alors aussi s'assurer que toutes les conversions on le temps 
	// d'être traitées entre les 2 déclenchements.
	ocInit.TIM_Pulse = 50;
	ocInit.TIM_OCPolarity = TIM_OCPolarity_Low;
	TIM_OC3Init(TIM2, &ocInit);
	
	// -- On définit TIM2 comme source de déclenchement.
	TIM_SelectOutputTrigger(TIM2, TIM_TRGOSource_Update);
	
	// -- On démarre le compteur.
	TIM_Cmd(TIM2, ENABLE);

	while (1) {
		printf("vrX = %d - vrY = %d\r\n", vrX, vrY);
		Delay_Ms(500);
	}
}

Canaux ordinaires + injectés, interruptions, déclenchement par timer

On peut très bien mélanger canaux ordinaires et injectés. Le cas de figure que nous allons voir maintenant pourrait correspondre au besoin de convertir 5 canaux en mode interruption. On utiliserait alors les 4 canaux injectés qu'on compléterait par un canal ordinaire.

Remplacez le contenu du fichier main.c par le code suivant :

#include <ch32v00x.h>
#include <debug.h>

volatile uint16_t vrX;
volatile uint16_t vrY;

__attribute__((interrupt("WCH-Interrupt-fast")))
void ADC1_IRQHandler() {
	if (ADC_GetITStatus(ADC1, ADC_IT_EOC)) {
		vrY = ADC_GetConversionValue(ADC1);
		ADC_ClearITPendingBit(ADC1, ADC_IT_EOC);
	}

	if (ADC_GetITStatus(ADC1, ADC_IT_JEOC)) {
		vrX = ADC_GetInjectedConversionValue(ADC1, ADC_InjectedChannel_1);
		ADC_ClearITPendingBit(ADC1, ADC_IT_JEOC);
	}
}

int main() {
	Delay_Init();
	USART_Printf_Init(115200);

	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOD, ENABLE);
	GPIO_InitTypeDef gpioInit = { 0 };
	// VRy = PD2 = A3
	// VRx = PD3 = A4
	gpioInit.GPIO_Pin = GPIO_Pin_2 | GPIO_Pin_3;
	gpioInit.GPIO_Mode = GPIO_Mode_AIN;
	GPIO_Init(GPIOD, &gpioInit);

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

	RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);
	RCC_ADCCLKConfig(RCC_PCLK2_Div8);

	ADC_InitTypeDef  adcInit = { 0 };
	adcInit.ADC_Mode = ADC_Mode_Independent;
	// On a plus qu'un seul canal ordinaire et un seul injecté, donc plus besoin de scan.
	adcInit.ADC_ScanConvMode = DISABLE;
	adcInit.ADC_ContinuousConvMode = DISABLE;
	adcInit.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T2_CC1;
	adcInit.ADC_DataAlign = ADC_DataAlign_Right;
	adcInit.ADC_NbrOfChannel = 1;
	ADC_Init(ADC1, &adcInit);

	// On active le déclenchement externe sur les canaux ordinaires.
	ADC_ExternalTrigConvCmd(ADC1, ENABLE);

	// On configure notre canal ordinaire.
	ADC_RegularChannelConfig(ADC1, ADC_Channel_3, 1, ADC_SampleTime_15Cycles);
	
	// On configure notre canal injecté.
	ADC_InjectedSequencerLengthConfig(ADC1, 1);
	ADC_InjectedChannelConfig(ADC1, ADC_Channel_4, 1, ADC_SampleTime_15Cycles);

	ADC_ExternalTrigInjectedConvConfig(ADC1, ADC_ExternalTrigInjecConv_T2_CC3);
	ADC_ExternalTrigInjectedConvCmd(ADC1, ENABLE);

	// On veut maintenant également l'interruption de fin de conversion
	// des canaux ordinaires.
	ADC_ITConfig(ADC1, ADC_IT_EOC | ADC_IT_JEOC, ENABLE);

	ADC_Cmd(ADC1, ENABLE);

	ADC_ResetCalibration(ADC1);
	while (ADC_GetResetCalibrationStatus(ADC1));
	ADC_StartCalibration(ADC1);
	while (ADC_GetCalibrationStatus(ADC1));

	RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
	TIM_TimeBaseInitTypeDef timeBaseInit = { 0 };
	timeBaseInit.TIM_Period = 100 - 1;
	timeBaseInit.TIM_Prescaler = 48000 - 1;
	timeBaseInit.TIM_ClockDivision = TIM_CKD_DIV1;
	timeBaseInit.TIM_CounterMode = TIM_CounterMode_Up;
	TIM_TimeBaseInit(TIM2, &timeBaseInit);

	TIM_OCInitTypeDef ocInit = { 0 };
	ocInit.TIM_OCMode = TIM_OCMode_PWM1;
	ocInit.TIM_OutputState = TIM_OutputState_Enable;
	ocInit.TIM_OCPolarity = TIM_OCPolarity_Low;
	// On lance d'abord la conversion du canal ordinaire
	ocInit.TIM_Pulse = 10;
	TIM_OC1Init(TIM2, &ocInit);
	// puis celle du canal injecté.
	ocInit.TIM_Pulse = 60;
	TIM_OC3Init(TIM2, &ocInit);

	TIM_SelectOutputTrigger(TIM2, TIM_TRGOSource_Update);
	TIM_Cmd(TIM2, ENABLE);
	
	while (1) {
		printf("vrX = %d - vrY = %d\r\n", vrX, vrY);
		Delay_Ms(500);
	}
}

Canaux ordinaires + injectés, interruptions, déclenchement par timer, enchaînement automatique

On peut configurer notre ADC pour qu'il enchaîne automatiquement avec la conversion des canaux injectés une fois que celle des canaux ordinaires est terminée. Ceci nous permet d'économiser un canal de timer, qui pourra servir pour autre chose.

Remplacez le contenu du fichier main.c par le code suivant :

#include <ch32v00x.h>
#include <debug.h>

volatile uint16_t vrX;
volatile uint16_t vrY;

__attribute__((interrupt("WCH-Interrupt-fast")))
void ADC1_IRQHandler() {
	if (ADC_GetITStatus(ADC1, ADC_IT_EOC)) {
		vrY = ADC_GetConversionValue(ADC1);
		ADC_ClearITPendingBit(ADC1, ADC_IT_EOC);
	}

	if (ADC_GetITStatus(ADC1, ADC_IT_JEOC)) {
		// Attention, l'indice a changé !
		vrX = ADC_GetInjectedConversionValue(ADC1, ADC_InjectedChannel_1);
		ADC_ClearITPendingBit(ADC1, ADC_IT_JEOC);
	}
}

int main() {
	Delay_Init();
	USART_Printf_Init(115200);

	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOD, ENABLE);
	GPIO_InitTypeDef gpioInit = { 0 };
	// VRy = PD2 = A3
	// VRx = PD3 = A4
	gpioInit.GPIO_Pin = GPIO_Pin_2 | GPIO_Pin_3;
	gpioInit.GPIO_Mode = GPIO_Mode_AIN;
	GPIO_Init(GPIOD, &gpioInit);

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

	RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);
	RCC_ADCCLKConfig(RCC_PCLK2_Div8);

	ADC_InitTypeDef  adcInit = { 0 };
	adcInit.ADC_Mode = ADC_Mode_Independent;
	adcInit.ADC_ScanConvMode = DISABLE;
	adcInit.ADC_ContinuousConvMode = DISABLE;
	adcInit.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T2_CC1;
	adcInit.ADC_DataAlign = ADC_DataAlign_Right;
	adcInit.ADC_NbrOfChannel = 1;
	ADC_Init(ADC1, &adcInit);

	ADC_ExternalTrigConvCmd(ADC1, ENABLE);

	ADC_RegularChannelConfig(ADC1, ADC_Channel_3, 1, ADC_SampleTime_15Cycles);
	
	ADC_InjectedSequencerLengthConfig(ADC1, 1);
	ADC_InjectedChannelConfig(ADC1, ADC_Channel_4, 1, ADC_SampleTime_15Cycles);

	// La conversion des canaux injectés est déclenchée à la suite de celle des canaux ordinaires.
	ADC_AutoInjectedConvCmd(ADC1, ENABLE);

	ADC_ITConfig(ADC1, ADC_IT_EOC | ADC_IT_JEOC, ENABLE);

	ADC_Cmd(ADC1, ENABLE);

	ADC_ResetCalibration(ADC1);
	while (ADC_GetResetCalibrationStatus(ADC1));
	ADC_StartCalibration(ADC1);
	while (ADC_GetCalibrationStatus(ADC1));

	RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
	TIM_TimeBaseInitTypeDef timeBaseInit = { 0 };
	timeBaseInit.TIM_Period = 100 - 1;
	timeBaseInit.TIM_Prescaler = 48000 - 1;
	timeBaseInit.TIM_ClockDivision = TIM_CKD_DIV1;
	timeBaseInit.TIM_CounterMode = TIM_CounterMode_Up;
	TIM_TimeBaseInit(TIM2, &timeBaseInit);

	TIM_OCInitTypeDef ocInit = { 0 };
	ocInit.TIM_OCMode = TIM_OCMode_PWM1;
	ocInit.TIM_OutputState = TIM_OutputState_Enable;
	ocInit.TIM_OCPolarity = TIM_OCPolarity_Low;
	// Plus besoin de TIM2CH3
	ocInit.TIM_Pulse = 10;
	TIM_OC1Init(TIM2, &ocInit);

	TIM_SelectOutputTrigger(TIM2, TIM_TRGOSource_Update);
	TIM_Cmd(TIM2, ENABLE);
	
	while (1) {
		printf("vrX = %d - vrY = %d\r\n", vrX, vrY);
		Delay_Ms(500);
	}
}

Présentation du DMA

DMA signifie Direct Memory Access, donc accès mémoire direct. Un contrôleur de DMA est un périphérique capable de faire des transferts de données pendant que le CPU fait autre chose. Ces transfert peuvent être de mémoire à mémoire, de périphérique à mémoire, ou de mémoire à périphérique.

Un contrôleur DMA est un périphérique extrêmement utile car il peut énormément simplifier le code et fluidifier son exécution en parallélisant des tâches. Pour permettre de maximiser son utilisation, le CH32V003 dispose de 8 canaux de DMA.

Chaque canal de DMA est associé à des périphériques spécifiques. Reportez-vous à la figure 8-1 "DMA1 request image" page 65 du manuel de référence pour plus de précisions. Par contre, tous les canaux peuvent être utilisés pour des transferts mémoire-mémoire.

Pour configurer un canal, on précise l'adresse d'origine des données, l'adresse de destination, la taille d'un élément (8/16/32 bits), le nombre d'éléments à transférer et quelques autres paramètres. Une fois le transfert lancé, tout est automatique. On notera que le contrôleur DMA est capable de gérer des buffers circulaires ("ring buffer" en anglais).

Le contrôleur de DMA est décrit au chapitre 8 "Direct Memory Access Control (DMA)" du manuel de référence.

Canaux ordinaires + injectés, interruptions + DMA, déclenchement par timer, enchaînement automatique

Pour démontrer la souplesse de configuration de notre ADC, nous allons maintenant combiner un canal ordinaire en mode DMA avec un canal injecté en mode interruptions.

Remplacez le contenu du fichier main.c par le code suivant :

#include <ch32v00x.h>
#include <debug.h>

volatile uint16_t vrX;
volatile uint16_t vrY;

__attribute__((interrupt("WCH-Interrupt-fast")))
void ADC1_IRQHandler() {
	if (ADC_GetITStatus(ADC1, ADC_IT_JEOC)) {
		vrX = ADC_GetInjectedConversionValue(ADC1, ADC_InjectedChannel_1);
		ADC_ClearITPendingBit(ADC1, ADC_IT_JEOC);
	}
}

int main() {
	Delay_Init();
	USART_Printf_Init(115200);

	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOD, ENABLE);
	GPIO_InitTypeDef gpioInit = { 0 };
	// VRy = PD2 = A3
	// VRx = PD3 = A4
	gpioInit.GPIO_Pin = GPIO_Pin_2 | GPIO_Pin_3;
	gpioInit.GPIO_Mode = GPIO_Mode_AIN;
	GPIO_Init(GPIOD, &gpioInit);

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

	RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);
	RCC_ADCCLKConfig(RCC_PCLK2_Div8);

	ADC_InitTypeDef  adcInit = { 0 };
	adcInit.ADC_Mode = ADC_Mode_Independent;
	adcInit.ADC_ScanConvMode = DISABLE;
	adcInit.ADC_ContinuousConvMode = DISABLE;
	adcInit.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T2_CC1;
	adcInit.ADC_DataAlign = ADC_DataAlign_Right;
	adcInit.ADC_NbrOfChannel = 1;
	ADC_Init(ADC1, &adcInit);

	ADC_ExternalTrigConvCmd(ADC1, ENABLE);

	ADC_RegularChannelConfig(ADC1, ADC_Channel_3, 1, ADC_SampleTime_15Cycles);
	
	ADC_InjectedSequencerLengthConfig(ADC1, 1);
	ADC_InjectedChannelConfig(ADC1, ADC_Channel_4, 1, ADC_SampleTime_15Cycles);

	ADC_AutoInjectedConvCmd(ADC1, ENABLE);

	ADC_ITConfig(ADC1, ADC_IT_JEOC, ENABLE);

	// Activer l'envoi d'événements de démarrage de transfert
	// au contrôleur de DMA.
	ADC_DMACmd(ADC1, ENABLE);

	ADC_Cmd(ADC1, ENABLE);

	ADC_ResetCalibration(ADC1);
	while (ADC_GetResetCalibrationStatus(ADC1));
	ADC_StartCalibration(ADC1);
	while (ADC_GetCalibrationStatus(ADC1));

	// -- Mise en service du contrôleur de DMA
	RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);
	
	// -- Configuration du canal de DMA utilisé pour le transfert.
	DMA_InitTypeDef dmaInit = { 0 };
	// L'origine du transfert est le registre ADC_RDATAR de notre ADC
	dmaInit.DMA_PeripheralBaseAddr = (uint32_t) &ADC1->RDATAR;
	// La destination du transfert est notre variable vrY
	dmaInit.DMA_MemoryBaseAddr = (uint32_t) &vrY;
	dmaInit.DMA_DIR = DMA_DIR_PeripheralSRC;
	// Nous n'avons qu'un seul élément à transférer (une seule variable). **
	dmaInit.DMA_BufferSize = 1;
	// Il n'y a qu'un seul registre ADC_RDATAR, donc rien à incrémenter
	dmaInit.DMA_PeripheralInc = DMA_PeripheralInc_Disable;
	// On n'a qu'une seule variable, donc pas d'incrémentation non plus. **
	dmaInit.DMA_MemoryInc = DMA_MemoryInc_Disable;
	// La taille d'un élément à transférer est de 16 bits (un demi-mot RISC-V).
	dmaInit.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord;
	dmaInit.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord;
	// Quand on a terminé le transfert, on revient au point de départ pour le suivant.
	// (cas général : buffer circulaire de DMA_BufferSize éléments)
	dmaInit.DMA_Mode = DMA_Mode_Circular;
	dmaInit.DMA_Priority = DMA_Priority_VeryHigh;
	dmaInit.DMA_M2M = DMA_M2M_Disable;
	// L'ADC n'envoie ses événements de démarrage de transfert qu'au 
	// canal 1 du contrôleur de DMA. Voir figure 8-1 "DMA1 request 
	// image" page 65 du manuel de référence.
	DMA_Init(DMA1_Channel1, &dmaInit);
	DMA_Cmd(DMA1_Channel1, ENABLE);
	// ** : ces paramètres sont spécifiques au cas particulier dans lequel nous sommes.
	// L'exemple 100% DMA qui suivra sera plus représentatif du cas général.

	RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
	TIM_TimeBaseInitTypeDef timeBaseInit = { 0 };
	timeBaseInit.TIM_Period = 100 - 1;
	timeBaseInit.TIM_Prescaler = 48000 - 1;
	timeBaseInit.TIM_ClockDivision = TIM_CKD_DIV1;
	timeBaseInit.TIM_CounterMode = TIM_CounterMode_Up;
	TIM_TimeBaseInit(TIM2, &timeBaseInit);

	TIM_OCInitTypeDef ocInit = { 0 };
	ocInit.TIM_OCMode = TIM_OCMode_PWM1;
	ocInit.TIM_OutputState = TIM_OutputState_Enable;
	ocInit.TIM_OCPolarity = TIM_OCPolarity_Low;
	ocInit.TIM_Pulse = 10;
	TIM_OC1Init(TIM2, &ocInit);

	TIM_SelectOutputTrigger(TIM2, TIM_TRGOSource_Update);
	TIM_Cmd(TIM2, ENABLE);
	
	while (1) {
		printf("vrX = %d - vrY = %d\r\n", vrX, vrY);
		Delay_Ms(500);
	}
}

Canaux ordinaires, 100% DMA, déclenchement par timer

Pour finir, nous allons utiliser 2 canaux ordinaires en mode DMA, ce qui nous permet d'exploiter au maximum le matériel pour faire le travail : il va même jusqu'à se charger de l'affectation du résultat des conversions à nos variables de travail !

En pratique, on essayera d'utiliser cette approche au maximum afin d'économiser les ressources du processeur et de réduire la complexité du code. Votre programme en aura toujours bien assez à faire, ne vous en faites pas pour ça !

Remplacez le contenu du fichier main.c par le code suivant :

#include <ch32v00x.h>
#include <debug.h>

// On utilise un tableau au lieu de 2 variables séparées
// pour s'assurer que les éléments soient bien contigus 
// et pas alignés sur des frontières de 32 bits.
#define VRx 1
#define VRy 0

uint16_t vr[2];

int main() {
	Delay_Init();
	USART_Printf_Init(115200);

	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOD, ENABLE);
	GPIO_InitTypeDef gpioInit = { 0 };
	// VRy = PD2 = A3
	// VRx = PD3 = A4
	gpioInit.GPIO_Pin = GPIO_Pin_2 | GPIO_Pin_3;
	gpioInit.GPIO_Mode = GPIO_Mode_AIN;
	GPIO_Init(GPIOD, &gpioInit);

	RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);
	RCC_ADCCLKConfig(RCC_PCLK2_Div8);

	ADC_InitTypeDef  adcInit = { 0 };
	adcInit.ADC_Mode = ADC_Mode_Independent;
	// On a 2 canaux à balayer
	adcInit.ADC_ScanConvMode = ENABLE;
	adcInit.ADC_ContinuousConvMode = DISABLE;
	adcInit.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T2_CC1;
	adcInit.ADC_DataAlign = ADC_DataAlign_Right;
	adcInit.ADC_NbrOfChannel = 2;
	ADC_Init(ADC1, &adcInit);

	ADC_ExternalTrigConvCmd(ADC1, ENABLE);

	ADC_RegularChannelConfig(ADC1, ADC_Channel_3, 1, ADC_SampleTime_15Cycles);
	ADC_RegularChannelConfig(ADC1, ADC_Channel_4, 2, ADC_SampleTime_15Cycles);

	ADC_DMACmd(ADC1, ENABLE);
	
	ADC_Cmd(ADC1, ENABLE);

	ADC_ResetCalibration(ADC1);
	while (ADC_GetResetCalibrationStatus(ADC1));
	ADC_StartCalibration(ADC1);
	while (ADC_GetCalibrationStatus(ADC1));

	RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);
	DMA_InitTypeDef dmaInit = { 0 };
	dmaInit.DMA_PeripheralBaseAddr = (uint32_t) &ADC1->RDATAR;
	dmaInit.DMA_MemoryBaseAddr = (uint32_t) vr;
	dmaInit.DMA_DIR = DMA_DIR_PeripheralSRC;
	// On indique le nombre d'éléments à transférer, pas le nombre d'octets !
	dmaInit.DMA_BufferSize = 2;
	dmaInit.DMA_PeripheralInc = DMA_PeripheralInc_Disable;
	// On doit incrémenter l'adresse mémoire après chaque transfert car 
	// il s'agit d'une nouvelle conversion, donc autre élément du tableau.
	dmaInit.DMA_MemoryInc = DMA_MemoryInc_Enable;
	dmaInit.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord;
	dmaInit.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord;
	dmaInit.DMA_Mode = DMA_Mode_Circular;
	dmaInit.DMA_Priority = DMA_Priority_VeryHigh;
	dmaInit.DMA_M2M = DMA_M2M_Disable;
	DMA_Init(DMA1_Channel1, &dmaInit);
	DMA_Cmd(DMA1_Channel1, ENABLE);

	RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
	TIM_TimeBaseInitTypeDef timeBaseInit = { 0 };
	timeBaseInit.TIM_Period = 100 - 1;
	timeBaseInit.TIM_Prescaler = 48000 - 1;
	timeBaseInit.TIM_ClockDivision = TIM_CKD_DIV1;
	timeBaseInit.TIM_CounterMode = TIM_CounterMode_Up;
	TIM_TimeBaseInit(TIM2, &timeBaseInit);

	TIM_OCInitTypeDef ocInit = { 0 };
	ocInit.TIM_OCMode = TIM_OCMode_PWM1;
	ocInit.TIM_OutputState = TIM_OutputState_Enable;
	ocInit.TIM_OCPolarity = TIM_OCPolarity_Low;
	ocInit.TIM_Pulse = 10;
	TIM_OC1Init(TIM2, &ocInit);

	TIM_SelectOutputTrigger(TIM2, TIM_TRGOSource_Update);
	TIM_Cmd(TIM2, ENABLE);
	
	while (1) {
		printf("vrX = %d - vrY = %d\r\n", vr[VRx], vr[VRy]);
		Delay_Ms(500);
	}
}

Ce que nous avons appris

Vous savez maintenant :

  • Ce qu'est un ADC et comment ça fonctionne.

  • Comment utiliser l'ADC du CH32V003 dans différentes configurations :

    • en mode polling

    • en mode interruption

    • en mode DMA

    • avec déclenchement par programme

    • avec déclenchement en continu

    • avec déclenchement par timer

    • avec enchaînement automatique canaux ordinaires puis injectés

    • en traitement ordinaire

    • en traitement injecté

    • avec un ou plusieurs canaux dans chaque liste


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