Ce document montre comment utiliser le convertisseur analogique/numérique (ADC) du microcontrôleur Nordic nRF52840, qui équipe l'arduino Arduino nano 33 BLE. Ce microcontrôleur comporte un processeur ARM Cortex M4, particulièrement adapté au traitement numérique du signal. Le Cortex M4 possède en effet une unité de calcul en virgule flottante et comporte des instructions optimisées pour le traitement numérique du signal (SMID : Single Instruction Multiple Data), en particulier pour le traitement du signal audio stéréo. Dans ce document, en verra un exemple de traitement du signal réalisé au moyen de la bibliothèque CMSIS-DSP (Cortex Microcontroller Software Interface Standard).
Le convertisseur A/N a une précision de 12 bits et peut numériser jusqu'à 200 000 échantillons par secondes. Le multiplexeur associé permet de numériser jusqu'à 8 voies en mode simple ou 4 voies en mode différentiel. Il comporte un amplificateur à gain réglable, qui permet d'effectuer une numérisation en haute résolution de signaux de très faible amplitude. Le convertisseur enregistre le signal numérisé directement en mémoire RAM, grace au mécanisme DMA (Direct Memory Access). Le microprocesseur est donc disponible pour des tâches de calcul lorsque le convertisseur travaille, ce qui est un avantage dans une application de traitement du signal.
La notice d'utilisation du microcontrôleur nRF52840 (nRF52840 Product Specification) détaille la programmation des périphériques associés au microprocesseur, en particulier le convertisseur SAADC (Successive Approximation Analog to Digital Converter). Nous aurons besoin aussi de programmer un TIMER (pour l'échantillonnage) et le PPI (Programmable peripheral interconnect), qui permet au Timer de déclencher la numérisation.
La configuration d'un périphérique se fait via des registres. Nous proposons une classe C++ NRF52_ADC qui permet d'utiliser facilement le convertisseur depuis un programme Arduino. L'intitulé NRF52 indique que cette classe fonctionne probablement sur tous les microcontrôleurs de la famille nRF52xxx, bien que nous l'ayons testé seulement sur le nRF52840. Voici le fichier entête :
#include "nrf.h"
#ifndef _NRF52_ADC_
#define _NRF52_ADC_
#define NRF52_ADC_GAIN_1_6 0
#define NRF52_ADC_GAIN_1_5 1
#define NRF52_ADC_GAIN_1_4 2
#define NRF52_ADC_GAIN_1_3 3
#define NRF52_ADC_GAIN_1_2 4
#define NRF52_ADC_GAIN_1 5
#define NRF52_ADC_GAIN_2 6
#define NRF52_ADC_GAIN_4 7
#define NRF52_ADC_RESOLUTION_8 0
#define NRF52_ADC_RESOLUTION_10 1
#define NRF52_ADC_RESOLUTION_12 2
#define NRF52_ADC_RESOLUTION_14 3
#define NRF52_ADC_OVERSAMPLE_NONE 0
#define NRF52_ADC_OVERSAMPLE_2 1
#define NRF52_ADC_OVERSAMPLE_4 2
#define NRF52_ADC_OVERSAMPLE_8 3
#define NRF52_ADC_OVERSAMPLE_16 4
#define NRF52_ADC_OVERSAMPLE_32 5
#define NRF52_ADC_OVERSAMPLE_64 6
#define NRF52_ADC_OVERSAMPLE_128 7
#define NRF52_ADC_OVERSAMPLE_256 8
#define NRF52_ADC_COMP_REF_1_2 0
#define NRF52_ADC_COMP_REF_1_8 1
#define NRF52_ADC_COMP_REF_2_4 2
#define NRF52_ADC_COMP_REF_VDD 4
class NRF52_ADC {
private:
const float gains[8] = {1.0/6,1.0/5,1.0/4,1.0/3,1.0/2,1.0,2.0,4.0};
const uint16_t resolution[4] = {256,1024,4096,16384};
const uint8_t voies[8] = {3,4,7,6,8,1,5,2};
const float vref = 0.6;
uint8_t nvoies;
uint16_t nechant;
uint8_t prescaler;
uint32_t compare;
int8_t oversample;
float fechant;
float ampl_conv[8];
void reset_channels();
void config_adc_timer();
void stop();
void (*end_callback)(void);
int16_t *buffer_1, *buffer_2;
uint8_t currentBuf;
bool continu;
uint32_t packCount;
uint32_t numPack;
static void _SAADC_IRQHandler();
void SAADC_IRQHandler();
static void _GPIOTE_IRQHandler();
public:
NRF52_ADC();
void config_voies(uint8_t nvoies, uint8_t *diff, uint8_t *ch_p, uint8_t *ch_n, uint8_t *igain, uint8_t iresolution=2);
void config_echant(uint8_t prescaler, uint32_t compare, int16_t nechant, int16_t *data_1, int16_t *data_2, void (*end_callback)(void)=0, uint8_t surechant=0);
float get_fechant();
float get_ampl_conv(uint8_t v=0);
uint32_t get_cc(float fe, uint8_t prescal);
void declencher(uint32_t numPack);
void declencher(uint8_t ch, uint8_t ref, uint8_t vdown, uint8_t vup, uint8_t sens, uint32_t numPack);
void declencher(int8_t pin, uint8_t sens, uint32_t numPack);
void stopper();
};
#endif
L'implémentation des différentes fonctions de cette classe est codée dans le fichier nrf52_adc.cpp, que nous allons décrire en détail.
L'activation des voies du multiplexeur se fait au moyen des registres CH[n].PSELP pour la borne positive et CH[n].PSELN pour la borne négative (utilisée en mode différentiel seulement). La fonction privée reset_channels désactive toutes les voies :
#include "Arduino.h"
#include "nrf52_adc.h"
#define PPI_SAMPLE_CH 10
#define PPI_TIMER_CH 11
#define PPI_END_CH 12
#define PPI_TRIGGER_CH 13
NRF52_ADC *NRF52_ADC_object;
NRF52_ADC::NRF52_ADC() {
NRF52_ADC_object = this;
}
void NRF52_ADC::reset_channels() {
for (uint8_t i=0; i<8; i++) {
NRF_SAADC->CH[i].PSELP = 0;
NRF_SAADC->CH[i].PSELN = 0;
}
}
La fonction publique config_voies effectue la configuration des voies :
void NRF52_ADC::config_voies(uint8_t nvoies, uint8_t *diff, uint8_t *ch_p, uint8_t *ch_n, uint8_t *igain, uint8_t iresolution) {
this->nvoies = nvoies;
NRF_SAADC->ENABLE = 1;
reset_channels();
for (uint8_t i=0; i<nvoies; i++) {
NRF_SAADC->CH[i].CONFIG = 0;
NRF_SAADC->CH[i].PSELP = voies[ch_p[i]];
if (diff[i]) {
NRF_SAADC->CH[i].PSELN = voies[ch_n[i]];
NRF_SAADC->CH[i].CONFIG |= 1<<20;
ampl_conv[i] = vref/(gains[igain[i]]*resolution[iresolution]/2);
}
else {
NRF_SAADC->CH[i].PSELN = 0;
ampl_conv[i] = vref/(gains[igain[i]]*resolution[iresolution]);
}
NRF_SAADC->CH[i].CONFIG |= (igain[i] << 8);
}
NRF_SAADC->RESOLUTION = iresolution;
NRF_SAADC->SAMPLERATE = 0;
}
Les arguments de cette fonction sont :
Précisons la gamme de tensions converties. La tension appliquée sur une borne doit être comprise entre 0 et VDD=3,3 V. Si l'on a besoin d'appliquer une tension plus basse que la masse, il faut ajouter un circuit d'adaptation. Le convertisseur fonctionne avec une référence interne vref=0.6 volts. Soient p le nombre de bits (en principe 12) et G le gain de l'amplificateur. Soient U la tension appliquée, soit entre la borne positive et la masse, soit entre la borne positive et la borne négative dans le cas du mode différentiel. Le nombre à p bits obtenu est donné par :
avec n=0 pour le mode simple et n=1 pour le mode différentiel. Dans ce second cas, l'entier fourni est signé. Par exemple pour p=12 bits, une conversion en mode simple donne un entier compris entre 0 et 4095 et une conversion en mode différentiel donne un entier compris entre -2048 et 2047 (codé par complément à deux, avec le bit p indiquant le signe). Voici la plage de tensions accessible (en mode simple) pour différentes valeurs du gain :
import numpy as np
Vref = 0.6
gain = np.array([1.0/6,1.0/5,1.0/4,1.0/3,1.0/2,1.0,2.0,4.0])
Umax = Vref/gain
print(Umax) --> array([3.6 , 3. , 2.4 , 1.8 , 1.2 , 0.6 , 0.3 , 0.15])
Par exemple pour un gain de 1/4 (NRF52_ADC_GAIN_1_4), on peut mesurer une tension de 0 à 2,4 V (en mode simple) et de -1,2 V à 1,2 V en mode différentiel. Le gain le plus élevé (NRF52_ADC_GAIN_4) permet de mesurer des tensions entre 0 et 150 mV (en mode simple).
En mode simple, il se produit une saturation lorsque la tension appliquée excède la valeur maximale. En revanche, une valeur négative est tolérée et donne bien un nombre négatif. Par exemple pour un gain de 1, nous avons réussi à numériser en mode simple une tension alternative comprise entre -600 mV et 600 mV. Cette propriété n'est pas mentionnée dans la documentation du microcontrôleur. Il est même écrit : The AIN0-AIN7 inputs cannot exceed VDD, or be lower than VSS, or VSS=0 volts. La documentation indique toutefois que, en mode simple, le convertisseur fonctionne en fait en mode différentiel mais avec une entrée négative connectée à la masse, ce qui implique en effet qu'une tension négative puisse être numérisée. À défaut d'information sur la tension négative maximale acceptable, nous préférons éviter d'appliquer une tension négative inférieure à -300 mV, car c'est la valeur minimale indiquée dans le tableau de spécifications électriques du microcontrôleur.
Voyons à présent la configuration de l'échantillonnage. Celui-ci est effectué au moyen d'un Timer (qu'on pourrait traduire par compteur ou chronomètre selon le contexte d'utilisation). Le microcontrôleur nRF52840 comporte 5 timers. Chacun de ces timers comporte un compteur (maximum 32 bits) qui est incrémenté à la fréquence suivante :
Le facteur de division (prescaler), est une entier codé sur 4 bits (de 0 à 15). La plus petite fréquence d'incrémentation du compteur est donc 1 MHz. Lorsque le compteur atteint sa valeur maximale (232 pour un compteur 32 bits), sa valeur revient à zéro. À chaque incrémentation du compteur, le timer compare sa valeur à 5 registres 32 bits appelés Capture/Compare (CC). Lorsque la valeur est égale à CC[0] (premier registre CC), un évènement EVENTS_COMPARE[0] est généré. Nous allons utiliser cet évènement pour déclencher la conversion A/N. La période d'échantillonnage sera ainsi :
Par exemple, une fréquence d'échantillonnage de 100 kHz pourra être obtenue avec prescaler=4 et CC=10. La période la plus grande est obtenue pour prescaler=15 et CC=232, soit environ 8,8 Ms. Si prescaler=0, la période maximale est d'environ 268 s. La courbe suivante montre la fréquence d'échantillonnage en fonction de CC :
from matplotlib.pyplot import *
CC = np.linspace(1,2**32,2,1000)
figure(figsize=(8,6))
plot(CC,16e6/CC,label='prescaler=0')
plot(CC,16e6/(CC*2),label='prescaler=1')
plot(CC,16e6/(CC*4),label='precaler=2')
plot(CC,16e6/(CC*8),label='precaler=3')
plot(CC,16e6/(CC*16),label='precaler=4')
grid()
legend(loc='upper right')
xlabel('CC')
ylabel('fe (Hz)')
xscale('log')
yscale('log')
fechant.pdf
CC = np.arange(1,1000)
figure(figsize=(8,6))
plot(CC,16e6/CC,label='prescaler=0')
plot(CC,16e6/(CC*2),label='prescaler=1')
plot(CC,16e6/(CC*4),label='precaler=2')
plot(CC,16e6/(CC*8),label='precaler=3')
plot(CC,16e6/(CC*16),label='precaler=4')
grid()
legend(loc='upper right')
xlabel('CC')
ylabel('fe (Hz)')
yscale('log')
fechant-2.pdf
La fonction config_echant mémorise le prescaler et la valeur de CC puis calcule la fréquence d'échantillonnage.
Lorsqu'on utilise une seule voie, il est possible d'effectuer un sur-échantillonnage, qui consiste à stocker en mémoire la moyenne n échantillons (de 2 à 256 échantillons). La fréquence d'échantillonnage du signal mémorisé est alors n plus petite que la fréquence d'échantillonnage réelle. Le filtrage passe-bas effectué avant réduction de la fréquence est un simple filtre moyenneur (moyenne arithmétique), qui n'est pas le filtre passe-bas optimal mais qui a l'avantage d'être effectué par le convertisseur lui-même. En cas de sur-échantillonnage (pour une seule voie seulement), la fréquence d'échantillonnage finale est réduite.
Les échantillons 12 bits sont directement écrits en mémoire par DMA (Direct Memory Access) dans deux tampons d'entiers 16 bits. Lorsque le premier tampon est rempli, l'ADC remplit le second tampon. Le premier est alors accessible en lecture pour analyse et traitement. Chaque tampon contient nechant échantillons 12 bits, qui constituent un paquet. Lorsque l'acquisition d'un paquet est terminée (c'est-à-dire lorsqu'un tampon est plein), une fonction est appelée pour le traitement du signal. Remarquons que le nombre d'échantillons pour chaque voie dans un paquet est égal à nechant divisée par le nombre de voies. On a donc intérêt à choisir la taille d'un paquet multiple du nombre de voies afin que toutes les voies aient le même nombre d'échantillons dans un paquet.
void NRF52_ADC::config_echant(uint8_t prescal, uint32_t cc, int16_t nechant, int16_t *data_1, int16_t *data_2,void (*end_callback)(void), uint8_t surechant) {
compare = cc;
prescaler = prescal;
oversample = surechant;
fechant = 16e6*1.0/(pow(2,prescaler)*cc*pow(2,oversample));
buffer_1 = data_1;
if (data_2!=0) { buffer_2 = data_2; continu = true; }
currentBuf = 0;
NRF_SAADC->RESULT.MAXCNT = nechant;
this->end_callback = end_callback;
if (this->nvoies!=1) surechant = 0;
NRF_SAADC->OVERSAMPLE = surechant;
}
Les arguments de cette fonction sont :
La fréquence d'échantillonnage maximale (déterminée expérimentalement) est égale à 200 kHz divisé par le nombre de voies. Si on dépasse cette valeur, on doit s'attendre à des résultats incorrects. On peut ainsi numériser 5 voies en fréquence audio (40 kHz), ou 2 voies sur-échantillonnées à 96 kHz. Dans le cas de la numérisation d'une seule voie, la fréquence d'échantillonnage réelle peut être supérieure à 200 kHz à condition d'utiliser le suréchantillonnage.
Pour obtenir la fréquence d'échantillonnage la plus proche possible d'une fréquence choisie (plus grande que 3,7 mHz), on choisit prescaler=0. La fonction suivante calcule la valeur de CC qui permet, pour un prescaler et un suréchantillonnage donnés, d'obtenir la fréquence d'échantillonnage la plus proche de celle choisie :
uint32_t NRF52_ADC::get_cc(float fe, uint8_t prescal, uint8_t surechant=0) {
return 16.0e6/(fe*pow(2,prescal)*pow(2,surechant));
}
La fonction get_fechant permet d'obtenir la la fréquence d'échantillonnage. En cas de suréchantillonnage, il s'agit de la fréquence d'échantillonnage finale, c'est-à-dire celle des échantillons enregistrés en mémoire.
float NRF52_ADC::get_fechant() {
return fechant;
}
La fonction get_ampl_conv permet d'obtenir le rapport entre la tension appliquée sur l'entrée de la voie v et le nombre 12 bits obtenu par conversion. Ce rapport n'a d'intérêt que si aucun convertisseur de tension n'est interposé entre la tension cherchée et l'entrée analogique. Si un convertisseur est utilisé, il faudra établir par étallonnage les coefficients de la transformation affine qui permet de passer du nombre 12 bits à la tension en entrée du convertisseur.
float NRF52_ADC::get_ampl_conv(uint8_t v) {
return ampl_conv[v];
}
La fonction privée config_adc_timer (fonction privée) configure le Timer et le PPI puis démarre l'ADC (mais pas le Timer).
void NRF52_ADC::config_adc_timer() {
NRF_SAADC->EVENTS_END = 0;
NRF_SAADC->EVENTS_STARTED = 0;
NRF_SAADC->EVENTS_DONE = 0;
NRF_SAADC->EVENTS_STOPPED = 0;
NRF_SAADC->ENABLE = 1;
// configuration du Timer3
NRF_TIMER3->TASKS_STOP = 1;
NRF_TIMER3->MODE = 0;
NRF_TIMER3->BITMODE = 3; // 32 bits
NRF_TIMER3->TASKS_CLEAR = 1;
NRF_TIMER3->PRESCALER = prescaler;
NRF_TIMER3->CC[0] = compare;
// configuration du Programmable Peripheral Interconnect
NRF_PPI->CH[PPI_SAMPLE_CH].EEP = (uint32_t)&NRF_TIMER3->EVENTS_COMPARE[0]; // event end point
NRF_PPI->CH[PPI_SAMPLE_CH].TEP = (uint32_t)&NRF_SAADC->TASKS_SAMPLE; // task end point
NRF_PPI->FORK[PPI_SAMPLE_CH].TEP = (uint32_t)&NRF_TIMER3->TASKS_CLEAR;
NRF_PPI->CHEN |= (1<< ((uint32_t)PPI_SAMPLE_CH));
NRF_PPI->CHENSET |= (1<< ((uint32_t)PPI_SAMPLE_CH));
if (continu) {
NRF_PPI->CH[PPI_END_CH].EEP = (uint32_t)&NRF_SAADC->EVENTS_END;
NRF_PPI->CH[PPI_END_CH].TEP = (uint32_t)&NRF_SAADC->TASKS_START;
NRF_PPI->CHEN |= (1<< ((uint32_t)PPI_END_CH));
NRF_PPI->CHENSET |= (1<< ((uint32_t)PPI_END_CH));
}
// interruptions
NRF_SAADC->INTENSET = 1 | (1 << 1); // interrupt enable : STARTED, END
NVIC_SetVector(SAADC_IRQn, (uint32_t)NRF52_ADC::_SAADC_IRQHandler);
NVIC_EnableIRQ(SAADC_IRQn);
}
Le PPI (Programmable Peripheral Interconnect) est un périphérique qui permet de coordonner des actions entre différents périphérique. Il permet de relier un évènement émis par un périphérique à une tâche reçue par un autre périphérique. Dans notre cas, l'évènement EVENTS_COMPARE[0] généré par le Timer déclenche la tâche TASKS_SAMPLE de l'ADC, qui consiste à effectuer l'échantillonnage des voies configurées.
La fonction declencher déclenche une acquisition en précisant le nombre de paquets à acquérir. Si ce nombre est nul, l'acquisision se répète indéfiniment jusqu'à son interruption par l'appel de la fonction stopper.
void NRF52_ADC::declencher(uint32_t numPack) {
config_adc_timer();
NRF_SAADC->RESULT.PTR = ((uint32_t)(buffer_1));
// déclenchement du SAADC et du TIMER
NRF_SAADC->TASKS_START = 1;
NRF_TIMER3->EVENTS_COMPARE[0] = 0;
NRF_TIMER3->TASKS_START = 1;
packCount = 0;
this->numPack = numPack;
}
La seconde version de la fonction declencher permet de déclencher à partir d'un seuil sur une entrée analogique (autre qu'une entrée utilisée par l'ADC). On utilise pour cela le comparateur (COMP). Celui-ci procède à partir d'une tension de référence (VREF) égale à 1,2 V, 1,8 V ou 2,4 V. Le seuil de déclenchement est déterminé à partir de cette tension de référence au moyen d'une échelle à 64 niveaux (codés sur 6 bits). Plus précisément, on définit un seuil bas (THDOWN) et (THUP) afin d'obtenir un déclenchement avec hystérésis. Les deux tensions seuil sont :
La tension de seuil est égale soit à VUP soit à VDOWN. Lorsque la tension U appliquée sur l'entrée analogique passe par le seuil par valeur croissante, le seuil passe à VDOWN. Lorsque la tension U passe par le seuil par valeur décroissante, le seuil passe à VUP. En choisissant VDOWN < VUP, on obtient un effet d'hystérésis qui évite des basculements multiples en présence de bruit (mais ce n'est pas important pour le déclenchement de l'ADC).
Un canal de PPI permet de déclencher le démarrage du Timer (qui actionne l'échantilloneur de l'ADC) à partir d'un évènement EVENTS_UP du COMP (passage du seuil par valeur croissante) ou EVENTS_DOWN (par valeur décroissante).
void NRF52_ADC::declencher(uint8_t ch, uint8_t ref, uint8_t vdown, uint8_t vup, uint8_t sens, uint32_t numPack) {
config_adc_timer();
NRF_SAADC->RESULT.PTR = ((uint32_t)(buffer_1));
NRF_TIMER3->EVENTS_COMPARE[0] = 0;
NRF_SAADC->TASKS_START = 1;
// configuration du comparateur
NRF_COMP->ENABLE = 1;
NRF_COMP->MODE = 2;
NRF_COMP->PSEL = voies[ch]-1;
NRF_COMP->REFSEL = ref;
NRF_COMP->TH = vdown | (vup << 8);
// PPI pour déclencher le Timer à partir de l'évènement EVENTS_UP
if (sens==1) NRF_PPI->CH[PPI_TIMER_CH].EEP = (uint32_t)&NRF_COMP->EVENTS_UP;
else NRF_PPI->CH[PPI_TIMER_CH].EEP = (uint32_t)&NRF_COMP->EVENTS_DOWN;
NRF_PPI->CH[PPI_TIMER_CH].TEP = (uint32_t)&NRF_TIMER3->TASKS_START;
NRF_PPI->CHEN |= (1<< ((uint32_t)PPI_TIMER_CH));
NRF_PPI->CHENSET |= (1<< ((uint32_t)PPI_TIMER_CH));
// déclenchement du comparateur
NRF_COMP->EVENTS_UP=0;
NRF_COMP->TASKS_START = 1;
packCount = 0;
this->numPack = numPack;
}
}
Les arguments de cette fonction sont :
La troisième version de la fonction declencher permet de faire le déclenchement sur un front montant ou descendant sur une entrée numérique. Le signal de déclenchement doit être numérique, c'est-à-dire une signal carré. Si le signal servant au déclenchement est d'une autre forme (par exemple sinusoïdal), utiliser la version précédente, qui donnera des résultats plus précis dans ce cas.
void NRF52_ADC::declencher(int8_t pin, uint8_t sens, uint32_t numPack) {
config_adc_timer();
NRF_SAADC->RESULT.PTR = ((uint32_t)(buffer_1));
NRF_TIMER3->EVENTS_COMPARE[0] = 0;
NRF_SAADC->TASKS_START = 1;
// configuration d'un évènement sur une entrée numérique
const uint8_t pins[14] = {0,0,11,12,15,13,14,23,21,27,2,1,8,13};
const uint8_t ports[14] = {0,0,1,1,1,1,1,0,0,0,1,1,1,0};
NRF_GPIOTE->CONFIG[0] = 1 | (pins[pin]<<8) | (ports[pin]<<13); // évènement rise sur pin
if (sens==1) NRF_GPIOTE->CONFIG[0] |= (1<<16);
else NRF_GPIOTE->CONFIG[0] |= (1<<17);
NRF_PPI->CH[PPI_TRIGGER_CH].EEP = (uint32_t)&NRF_GPIOTE->EVENTS_IN[0];
NRF_PPI->CH[PPI_TRIGGER_CH].TEP = (uint32_t)&NRF_TIMER3->TASKS_START;
//NRF_PPI->FORK[PPI_TRIGGER_CH].TEP = (uint32_t)&NRF_SAADC->TASKS_START;
NRF_PPI->CHEN |= (1<< ((uint32_t)PPI_TRIGGER_CH));
NRF_PPI->CHENSET |= (1<< ((uint32_t)PPI_TRIGGER_CH));
NRF_GPIOTE->INTENSET |= 1;
NVIC_SetVector(GPIOTE_IRQn, (uint32_t)NRF52_ADC::_GPIOTE_IRQHandler);
NVIC_EnableIRQ(GPIOTE_IRQn);
packCount = 0;
this->numPack = numPack;
}
]
la fonction privée stop effectue l'arrêt de l'acquisition.
void NRF52_ADC::stop() {
NRF_GPIOTE->INTENCLR |= 1;
NRF_GPIOTE->CONFIG[0] = 0;
NRF_TIMER3->TASKS_STOP = 1;
NRF_SAADC->TASKS_STOP = 1;
NRF_SAADC->ENABLE = 0;
NVIC_DisableIRQ(GPIOTE_IRQn);
NVIC_DisableIRQ(SAADC_IRQn);
NRF_PPI->CHENCLR |= ((1<< ((uint32_t)PPI_TRIGGER_CH))|(1<< ((uint32_t)PPI_TIMER_CH))|(1<< ((uint32_t)PPI_SAMPLE_CH))|(1<< ((uint32_t)PPI_END_CH)));
NRF_PPI->CHEN &= ~ ((1<< ((uint32_t)PPI_TRIGGER_CH))|(1<< ((uint32_t)PPI_TIMER_CH))|(1<< ((uint32_t)PPI_SAMPLE_CH))|(1<< ((uint32_t)PPI_END_CH)));
}
La fonction public permettant de stopper l'acquisition est stopper :
void NRF52_ADC::stopper() {
continu = false;
}
La fonction SAADC_IRQHandler est le gestionnaire d'interruption du convertisseur ADC. Il y a deux types d'interruption :
void NRF52_ADC::SAADC_IRQHandler() {
if (NRF_SAADC->EVENTS_END==1) {
NRF_SAADC->EVENTS_END = 0;
if (end_callback!=0) end_callback();
packCount++;
if (packCount==numPack) stop();
}
if (NRF_SAADC->EVENTS_STARTED==1) {
NRF_SAADC->EVENTS_STARTED = 0;
if (currentBuf==0) {
currentBuf = 1;
NRF_SAADC->RESULT.PTR = ((uint32_t)(buffer_2));
}
else {
currentBuf = 0;
NRF_SAADC->RESULT.PTR = ((uint32_t)(buffer_1));
}
}
}
La fonction suivante est le gestionnaire d'interruption spécifié par l'appel de NVIC_SetVector. Il s'agit d'une fonction statique. La variable globale NRF52_ADC_object contient le pointeur vers l'instance de NRF52_ADC. La nécessité d'utiliser une fonction statique comme gestionnaire d'interruption implique qu'il est impossible d'utiliser en même temps plusieurs instances de NRF52_ADC (ce qui n'aurait de toute façon aucun intérêt puisqu'il n'y a qu'un seul ADC).
void NRF52_ADC::_SAADC_IRQHandler() {// fonction statique}
NRF52_ADC_object->SAADC_IRQHandler();
}
Dans cette partie, nous présentons un exemple d'utilisation de la classe précédente, qui permet de tester ses différentes fonctions. Pour vérifier le bon fonctionnement de l'échantillonnage, une analyse spectrale est effectuée, grace à la fonction FFT de la bibliothèque CMSIS-DSP.
#include <arm_math.h> // CMSIS DSP
#include "nrf52_adc.h"
#define NSAMPLES 2048 // nombre d'échantillons
#define FECHANT 40e3 // fréquence d'échantillonnage
#define SURECHANT NRF52_ADC_OVERSAMPLE_4
NRF52_ADC adc;
int16_t data_1[NSAMPLES];
int16_t data_2[NSAMPLES];
uint8_t diff[1] = {0};
uint8_t ch_p[1] = {0};
uint8_t ch_n[1] = {1};
uint8_t gains[1] = {0};
bool data_ready;
float32_t data_f32[NSAMPLES];
arm_rfft_fast_instance_f32 rfft_instance;
float32_t fft[NSAMPLES*2];
float32_t fft_amp[NSAMPLES];
float deltaf;
uint8_t nTamp;
float minA;
// transformation affine : nombre 12 bits -> tension
float a = 10.0/4096;
float b = -5.0;
Les coefficients de la transformation nombre/tension correspondent à l'utilisation d'un circuit analogique qui convertit une tension dans l'intervalle [-5,5] volts en tension dans l'intervalle [0,3.3] volts.
La fonction end_callback est appellée lorsque l'acquisition d'un paquet d'échantillons est terminée. Le tampon de nombres 16 bits qui a été utilisé pour ce paquet est data_1 ou data_2 en fonction de la valeur de nTamp. Les valeurs 12 bits sont converties en tensions et stockées dans le tableau data_f32. Pour finir, data_ready est mis à true pour indiquer que les données sont prêtes pour le traitement.
void end_callback() {
if (nTamp==0) {
for (int indice=0; indice<NSAMPLES; indice++) data_f32[indice] = data_1[indice]*a+b;
nTamp = 1;
}
else {
for (int indice=0; indice<NSAMPLES; indice++) data_f32[indice] = data_2[indice]*a+b;
nTamp = 0;
}
data_ready = true;
}
Dans la fonction setup, on configure l'ADC.
void setup() {
Serial.begin(115200);
adc.config_voies(1,diff,ch_p,ch_n,gains,NRF52_ADC_RESOLUTION_12);
uint32_t cc = adc.get_cc(FECHANT,0,SURECHANT);
adc.config_echant(0,cc,NSAMPLES,data_1,data_2,end_callback,SURECHANT);
float fechant = adc.get_fechant();
deltaf = fechant/NSAMPLES;
Serial.print("fe = "); Serial.print(fechant); Serial.println(" Hz");
Serial.println("----------------");
}
Dans la fonction loop, le port série est lu. Si l'utilisateur saisi un chiffre entre 1 et 9, une acquisition de deux paquets est lancée. Le chiffre entré permet de définir l'amplitude minimale des raies du spectre qui seront affichées. Le traitement des données se fait lorsque le booléen data_ready indique que ces données sont disponibles, ce qui se réalise dès qu'un paquet a été converti. Le traitement consiste à calculer la transformée de Fourier discrète (TFD) et à afficher les amplitudes des composantes de la TFD dont la valeur dépasse minA.
void loop() {
delay(10);
char com;
if (Serial.available()>0) {
com = Serial.read();
if ((com>=49)&&(com<=57)) { // chiffre
minA = 0.2*(com-48);
data_ready = false;
nTamp = 0;
adc.declencher(2);
}
}
if (data_ready) {
data_ready = false;
arm_rfft_fast_init_f32(&rfft_instance,NSAMPLES);
arm_rfft_fast_f32(&rfft_instance,data_f32,fft,0);
arm_cmplx_mag_f32(fft,fft_amp,NSAMPLES);
float amp,f;
for (int i=0; i<NSAMPLES/2; i++) {
amp = fft_amp[i]*2/NSAMPLES;
f = i*deltaf;
if (amp > minA) {
Serial.print(f,6);
Serial.print(" Hz, ");
Serial.println(amp);
}
}
Serial.println("----------------");
}
}
Cette partie présente un programme Arduino et un script Python chargé de configurer l'ADC et de récupérer les signaux, ce qui permet de faire un test approfondi des signaux numériques obtenus.
La conversion se fait sur une ou deux voies mais le choix de ces voies et le mode (différentiel ou pas) sont codés en dur.
Le protocole de transmission des données est décrit dans Échanges de données avec un Arduino. Chaque donnée transmise entre l'Arduino et le PC possède un numéro d'identification (entier 8 bits). Dans le cas présent, les données de configuration sont transmises seulement du PC vers l'Arduino alors que les tableaux d'échantillons des signaux sont transmis de l'Arduino vers le PC. Chaque donnée est caractérisée par un nombre d'entiers 8 bits (ou nombre de caractères transmis). Voici ces données :
Les données ci-dessous sont en fait des ordres de déclenchement transmises du PC ver l'Arduino. La taille de la donnée est donc nulle.
L'échange d'une donnée est initiée par le PC en envoyant un caractère. Le caractère défini par la macro GET_DATA indique que le PC fait une demande de lecture de donnée (DATA_0 seulement), alors que SET_DATA indique que le PC fait une demande d'écriture d'une donnée.
Le nombre d'échantillons (NSAMPLES) doit être une puissance de 2 car la transmission se fait par paquets dont la taille est aussi une puissance de 2.
Voici l'entête du code source :
#include "nrf52_adc.h"
#define GET_DATA 10
#define SET_DATA 11
#define NSAMPLES 8192
NRF52_ADC adc;
int16_t tampon_1[NSAMPLES];
int16_t tampon_2[NSAMPLES];
uint8_t nTamp;
bool data_0_ready, data_0_request;
// data_1 : cc (32 bits)
#define DATA_1_SIZE 4
uint8_t data_1[DATA_1_SIZE];
uint32_t cc = 100;
// data_2 : prescaler (8 bits)
#define DATA_2_SIZE 1
uint8_t data_2[DATA_2_SIZE];
uint8_t prescaler = 4;
// data_3 : surechant (8 bits)
#define DATA_3_SIZE 1
uint8_t data_3[DATA_3_SIZE];
uint8_t surechant = 0;
// data_4 : nvoies (8 bits)
#define DATA_4_SIZE 1
uint8_t data_4[DATA_4_SIZE];
uint8_t nvoies = 1;
// data_5 : npack (32 bits)
#define DATA_5_SIZE 4
uint8_t data_5[DATA_5_SIZE];
uint32_t npack = 1;
// data_6 : ch_trigger, ref, vdown, vup, sens
#define DATA_6_SIZE 5
uint8_t data_6[DATA_6_SIZE];
uint8_t ch_trigger,ref,vdown,vup,sens;
// data_7 : pin_trigger, sens
#define DATA_7_SIZE 2
uint8_t data_7[DATA_7_SIZE];
uint8_t pin_trigger;
#define DATA_10_SIZE 0 // déclencher
#define DATA_11_SIZE 0 // déclencher avec entrée analogique
#define DATA_12_SIZE 0 // déclencher avec entrée numérique
uint8_t diff[2] = {0,0};
uint8_t ch_p[2] = {0,2};
uint8_t ch_n[2] = {1,3};
uint8_t gains[2] = {0,0};
La fonction end_callback est appelée dès que la numérisation d'un paquet (de taille NSAMPLES) est terminée. Son rôle est de changer le numéro du tampon de mémoire utilisé pour le stockage des échantillons et de déclarer qu'un paquet est disponible pour la transmission vers le PC.
void end_callback() {
if (nTamp==0) nTamp = 1;
else nTamp = 0;
data_0_ready = true;
}
La fonction send_data est chargée d'envoyer des données au PC, après que celui-ci en ait fait la demande. Dans le cas présent, seule la donnée numéro 0 est concernée et il s'agit de transmettre la totalité du contenu d'un tampon de NSAMPLES nombres de 16 bits. Le tampon est transmis si le PC en a fait la demande (data_0_request) et si la conversion a été effectuée (data_0_ready). Les valeurs à transmettre sont dans le tampon qui a été utilisé pour la dernière acquisition d'un paquet, soit dans tampon_1 soit dans tampon_2. Il est impossible de transmettre la totalité du tampon en un seul appel de la fonction Serial.write. On doit procéder par paquets de 32 caractères (64 fonctionne aussi mais n'apporte pas de gain de vitesse).
void send_data() {
if ((data_0_request)&&(data_0_ready)) {
data_0_ready = false;
data_0_request = false;
uint8_t* dat;
if (nTamp==1) dat = (uint8_t*) tampon_1;
else dat = (uint8_t*) tampon_2;
#define BLOCKSIZE 32
#define SAMPLESIZE 2
for (int b=0; b<NSAMPLES*SAMPLESIZE/BLOCKSIZE; b++) Serial.write(dat+b*BLOCKSIZE,BLOCKSIZE); // 80ms pour 8192*4 octets, soit 262000 bit/s
}
}
La fonction get_data est appelée après que le PC ait fait une demande de lecture d'un paquet (DATA_0). Son rôle est simplement d'enregistrer cette demande au moyen du booléen data_0_request. La transmission du paquet se fera lorsque le tampon sera rempli.
void get_data() {
char n;
while (Serial.available()<1) {};
n = Serial.read();
if (n==0) data_0_request = true;
}
La fonction set_data est appelée après que le PC ait fait une demande d'écriture d'une donnée. En fonction du numéro de la donnée, elle effectue la lecture dans un tampon (tableau d'entiers 8 bits) puis copie les données dans les variables correspondantes. Par exemple, data_1 est copiée dans la variable cc (4 entiers 8 bits copiés dans un entier 32 bits).
Les données numéros 10,11 et 12 sont en fait des commandes, sans donnée associée. Par exemple DATA_10 permet de configurer l'ADC et de déclencher l'acquisition (déclenchement logiciel).
void set_data() {
char n;
while (Serial.available()<1) {};
n = Serial.read();
if (n==1) {
while (Serial.available()<DATA_1_SIZE) {};
Serial.readBytes(data_1,DATA_1_SIZE);
memcpy(&cc,data_1,DATA_1_SIZE);
}
else if (n==2) {
while (Serial.available()<DATA_2_SIZE) {};
Serial.readBytes(data_2,DATA_2_SIZE);
memcpy(&prescaler,data_2,DATA_2_SIZE);
}
else if (n==3) {
while (Serial.available()<DATA_3_SIZE) {};
Serial.readBytes(data_3,DATA_3_SIZE);
memcpy(&surechant,data_3,DATA_3_SIZE);
}
else if (n==4) {
while (Serial.available()<DATA_4_SIZE) {};
Serial.readBytes(data_4,DATA_4_SIZE);
memcpy(&nvoies,data_4,DATA_4_SIZE);
}
else if (n==5) {
while (Serial.available()<DATA_5_SIZE) {};
Serial.readBytes(data_5,DATA_5_SIZE);
memcpy(&npack,data_5,DATA_5_SIZE);
}
else if (n==6) {
while (Serial.available()<DATA_6_SIZE) {};
Serial.readBytes(data_6,DATA_6_SIZE);
ch_trigger = data_6[0];
ref = data_6[1];
vdown = data_6[2];
vup = data_6[3];
sens = data_6[4];
}
else if (n==7) {
while (Serial.available()<DATA_7_SIZE) {};
Serial.readBytes(data_7,DATA_7_SIZE);
pin_trigger = data_7[0];
sens = data_7[1];
}
else if (n==10) {
adc.config_voies(nvoies,diff,ch_p,ch_n,gains,NRF52_ADC_RESOLUTION_12);
adc.config_echant(prescaler,cc,NSAMPLES,tampon_1,tampon_2,end_callback,surechant);
nTamp = 0;
adc.declencher(npack);
}
else if (n==11) {
adc.config_voies(nvoies,diff,ch_p,ch_n,gains,NRF52_ADC_RESOLUTION_12);
adc.config_echant(prescaler,cc,NSAMPLES,tampon_1,tampon_2,end_callback,surechant);
nTamp = 0;
adc.declencher(ch_trigger,ref,vdown,vup,sens,npack);
}
else if (n==11) {
adc.config_voies(nvoies,diff,ch_p,ch_n,gains,NRF52_ADC_RESOLUTION_12);
adc.config_echant(prescaler,cc,NSAMPLES,tampon_1,tampon_2,end_callback,surechant);
nTamp = 0;
adc.declencher(pin_trigger,sens,npack);
}
}
La fonction read_serial sert à scruter le port série pour savoir si le PC a fait une demande.
void read_serial() {
char com;
if (Serial.available()>0) {
com = Serial.read();
if (com==GET_DATA) get_data();
else if (com==SET_DATA) set_data();
}
}
Voici la fonction setup :
void setup() {
Serial.begin(1000000);
data_0_ready = false;
data_0_request = false;
}
Voici la fonction loop :
void loop() {
read_serial();
send_data();
}
La boucle principale du programme consiste à scruter le port série et à traiter les demandes provenant du PC. Si le PC fait une demande d'écriture d'une donnée, celle-ci est traitée immédiatement. S'il fait une demande de lecture d'un paquet d'échantillons (DATA_0), cette demande est traitée de manière asynchrone car la donnée n'est pas en général disponible au moment de la demande. Le transfert du paquet se fait dans la fonction sens_data, si un paquet est effectivement disponible. Il faut remarquer qu'aucune de ces deux fonctions n'est bloquante.
Les scripts Python utilisent la classe Arduino décrite dans Échanges de données avec un Arduino. Cette classe est définie dans le fichier Arduino.py.
Le premier script Python permet de numériser un signal sur la voie A0. Le signal et son spectre son tracés.
from Arduino import Arduino
import numpy as np
import matplotlib.pyplot as plt
from numpy.fft import fft
bufsize = 8192
ard = Arduino('COM9',[bufsize*2,4,1,1,4,5],baudrate=1000000)
cc = 10
prescaler = 3
surechant = 2
nvoies = 1
npack = 2
ard.write_int32(1,cc)
ard.write_int8(2,prescaler)
ard.write_int8(3,surechant)
ard.write_int8(4,nvoies)
ard.write_int32(5,npack)
ard.write_int8_array(6,[1,2,10,20,1],False)
ard.write(10) # déclenchemrn logiciel
#ard.write(11) # déclenchement sur A1
signal_1 = np.zeros((0))
for i in range(npack):
data = ard.read_int16_array(0)
signal_1 = np.append(signal_1,data)
ard.close()
fechant = 16e6/(cc*2**prescaler*2**surechant)
print("fe = %f"%fechant)
Ne = len(signal_1)
print("Ne = %d"%Ne)
t = np.arange(Ne)*1/fechant
tfd = fft(signal_1)
T = Ne/fechant
print("T = %f"%T)
freq = np.arange(Ne)*1/T
plt.figure()
plt.plot(t,signal_1)
plt.ylim(0,4096)
plt.xlabel('t (s)')
plt.grid()
plt.figure()
plt.plot(freq,np.absolute(tfd)*2/Ne)
plt.grid()
plt.xlabel('f (Hz)')
np.save("sinus-100Hz-12bits-50kHz-overs2.npy",np.array([t,signal_1]))
plt.show()
Dans cet exemple, la fréquence d'échantillonnage est 200 kHz et le suréchantillonnage est 2, ce qui signifie que la fréquence d'échantillonnage du signal stocké en mémoire est 50 kHz. Il y a 2 paquets, soit 16384 échantillons, pour une durée totale de 0.32768 s.
Voici le tracé du signal numérique obtenu (nombres entiers non convertis en tensions), pour un signal sinusoïdal de fréquence 100 Hz :
[t,u0] = np.load("sinus-100Hz-12bits-50kHz-overs2.npy")
figure(figsize=(8,6))
plot(t,u0)
grid()
ylim(0,4096)
xlim(0,0.1)
xlabel("t (s)",fontsize=16)
ylabel("u0 (12 bits)",fontsize=16)
figC.pdf
La même fréquence d'échantillonnage finale sans suréchantillonnage est obtenue avec cc=40 et surechant=0. Voici le résultat :
[t,u0] = np.load("sinus-100Hz-12bits-50kHz-overs0.npy")
figure(figsize=(8,6))
plot(t,u0)
grid()
ylim(0,4096)
xlim(0,0.1)
xlabel("t (s)",fontsize=16)
ylabel("u0 (12 bits)",fontsize=16)
figD.pdf
Le signal numérique est affecté d'un bruit important. Si on applique sur A0 une tension constante dont le bruit est de 5 mV RMS, le signal numérique possède un bruit d'amplitude RMS de 6 unités, ce qui correspond à 14 mV (car la plage de tensions numérisée s'étend sur 10 V). Un bruit important est donc introduit par l'ADC. Ce bruit est dû à un défaut de conception du circuit de régulation de tension de l'Arduino nano 33 BLE qui convertit la tension 5 V de l'USB en tension 3.3 V (le condensateur de sortie du convertisseur à découpage est mal placé). Ce problème limite l'intérêt du convertisseur 12 bits. Si on l'utilise en mode 10 bits, l'amplitude du bruit devient 1,5 unités RMS environ, de l'ordre de grandeur du bruit de quantification. Les signaux numérisés pourront cependant être filtrés aisément avec des fonctions CMSIS-DSP et dans ce cas il est préférable de numériser en 12 bits afin de faire le filtrage en 12 bits.