Table des matières

Arduino 33 BLE : mesures de temps et de fréquence

1. Introduction

Ce document montre comment faire des mesures de temps et de fréquences sur un Arduino nano 33 BLE. La mesure de temps consiste à mesurer l'intervalle de temps entre deux fronts montants d'un signal numérique. La mesure de fréquence consiste à compter les fronts montants pendant une durée déterminée. Plus généralement, on verra comment faire l'enregistrement temporel d'un signal binaire, par exemple pour décoder une séquence de bits.

Le code présenté utilise la programmation directe des périphériques du microcontrôleur Nordic nRF52840. On utilise également l'API du système d'exploitation MBED OS.

2. Mesure de fréquence

2.a. Utilisation de l'API MBED

Il s'agit de compter les fronts montants (ou descendants) sur une entrée numérique pendant un intervalle de temps choisi. Pour détecter les fronts montants, on utilise le mécanisme d'interruption. Le contrôleur d'interruption est un périphérique associé au processeur, qui se charge de déclencher l'interruption du programme en cours d'exécution en réponse à différents événements, en particulier un changement d'état sur une entrée numérique. Lors d'une interruption, un programme secondaire (gestionnaire d'interruption) est appelé. Lorsque ce programme est terminé, le processeur est ramené à l'exécution du programme qu'il traitait avant le déclenchement de l'interruption. Le déclenchement d'une interruption est très rapide, avec un délai de l'ordre de quelques cycles d'horloge. Pour configurer une interruption matérielle à partir d'une entrée numérique, on utilisera la classe mbed::InterruptIn.

En première approche, nous faisons la mesure du temps grace à la classe mbed::Timer (qui utilise un périphérique du processeur appelé Timer). Cette classe permet de faire des mesures de temps, avec en principe une précision de l'ordre de la microseconde au moyen de la fonction read_us. Nous adoptons l'algorithme suivant : à chaque interruption matérielle déclenchée par un front montant sur l'entrée choisie, une variable 32 bits (compteur) est incrémentée. Pour faire la mesure, on initialise ce compteur puis on enregistre le temps avec read_us, avant de mettre la tâche d'exécution en attente grace à la fonction rtos::ThisThread::sleep_for. Pour finir, on mesure à nouveau le temps et on relève l'état du compteur, ce qui permet de calculer la fréquence. Voici le programme arduino qui réalise cette mesure, avec un signal PWM généré sur une sortie afin de faire les tests :

frequencemetre_1.ino
#include "Arduino.h"
#include "nrf.h"
#include "mbed.h"

#define INPUT_PIN 3 // entrée de mesure
#define OUTPUT_PIN 4 // sortie à relier à l'entrée (pour tester)
#define DUREE 1000 // durée en millisecondes de la mesure

uint32_t compteur,t1,t2;
float frequence;
mbed::InterruptIn inter(digitalPinToPinName(INPUT_PIN));
mbed::Timer temps;
mbed::PwmOut pwm(digitalPinToPinName(OUTPUT_PIN));

// gestionnaire d'interruption pour front montant sur INPUT_PIN
void f_rise() {
  compteur += 1;
}

// mesure de fréquence 
float mesure_frequence() {
  temps.start();
  t1 = temps.read_us();
  compteur = 0;
  rtos::ThisThread::sleep_for(DUREE);
  t2 = temps.read_us();
  frequence = 1.0*compteur/(t2-t1)*1e6;
  temps.stop();
}

void setup() {
  Serial.begin(115200);
  pinMode(INPUT_PIN,INPUT);
  pwm.period_us(100); // génération d'un signal carré de 10 kHz
  pwm.write(0.5); // rapport cyclique
  inter.rise(&f_rise); // configuration de l'interruption matérielle
}

void loop() {
   mesure_frequence();                                                                                                    
   Serial.print(frequence); Serial.println(" Hz");
}

    	        

2.b. Programmation directe des Timers

Le programme précédent fonctionne pour une fréquence de signal jusqu'à environ 50kHz. Au delà, la fréquence des appels du gestionnaire d'interruption est trop grande et un blocage le l'OS se produit. Pour augmenter la vitesse de traitement (et la précision de la mesure), nous allons faire une programmation directe des Timers. La programmation directe des périphériques est expliquée dans la notice d'utilisation du microcontrôleur nRF52840. Bien sûr, le code ne sera pas portable vers d'autres microcontrôleurs, contrairement au code précédent qui est compatible avec toute plateforme gérée par MBED OS.

Un Timer comporte un compteur 32 bits incrémenté soit automatiquement à une fréquence égale à la fréquence d'horloge (16MHz) divisée par 2prescaler (Mode Timer), soit à partir d'une action extérieure (mode compteur). On utilise deux Timers. Le TIMER3 (en mode timer) sert à faire la mesure du temps. Le TIMER4 (en mode compteur) sert à compter les fronts montants. Nous utilisons également le PPI (Programmable Peripheral Interconnect), un périphérique qui permet à un événement (EVENT) déclenché par un périphérique de déclencher une tâche (TASK) sur un autre périphérique. L'évènement est un front montant sur une entrée, qui est déclenché grace au PGIOTE (GPIO Task and Event). Cet évènement déclenchera la tâche TASKS_COUNT du TIMER4, qui incrémente son compteur. Le compteur du TIMER3 est incrémenté jusqu'à la valeur définie dans son registre CC[0] (registre compare/capture). Lorsqu'il atteint cette valeur, un évènement EVENT_COMPARE[0] est généré, lequel génère une tâche TASKS_CAPTURE[0] dans le TIMER4, ce qui a pour effet de recopier la valeur de son compteur (donc le nombre de fronts comptés) dans le registre CC[0] du TIMER4.

frequencemetre_2.ino
#include "Arduino.h"
#include "nrf.h"
#include "mbed.h"

#define INPUT_PIN 3 // entrée de mesure
#define OUTPUT_PIN 4 // sortie à relier à l'entrée (pour tester)
#define PPI_COUNT_CH 10 // canaux PPI
#define PPI_CAPTURE_CH 11

float frequence;
mbed::PwmOut pwm(digitalPinToPinName(OUTPUT_PIN));
uint8_t prescaler; // prescaler pour TIMER3
uint32_t compare; // CC[0] pour TIMER 3

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};

// mesure de fréquence sur une durée égale à 1/(16e6)*2^(prescaler)*compare
void mesure_frequence() {
  NRF_GPIOTE->CONFIG[0] = 1 | (pins[INPUT_PIN]<<8) | (ports[INPUT_PIN]<<13) | (1<<16); // evènement rising edge sur INPUT_PIN
  
  NRF_TIMER4->TASKS_STOP = 1;
  NRF_TIMER4->MODE = 1; // counter mode
  NRF_TIMER4->BITMODE = 3; // 32 bits
  NRF_TIMER4->TASKS_CLEAR = 1;
  
  
  NRF_PPI->CH[PPI_COUNT_CH].EEP = (uint32_t)&NRF_GPIOTE->EVENTS_IN[0];
  NRF_PPI->CH[PPI_COUNT_CH].TEP = (uint32_t)&NRF_TIMER4->TASKS_COUNT; // incrémenter le compteur du timer 4 lorsque INPUT_PIN passe de 0 à 1
  NRF_PPI->CHEN |= (1<< ((uint32_t)PPI_COUNT_CH));
  NRF_PPI->CHENSET |= (1<< ((uint32_t)PPI_COUNT_CH));
   
  NRF_TIMER3->TASKS_STOP = 1;
  NRF_TIMER3->MODE = 0; // timer mode
  NRF_TIMER3->BITMODE = 3; // 32 bits
  NRF_TIMER3->TASKS_CLEAR = 1;
  NRF_TIMER3->PRESCALER = prescaler;
  NRF_TIMER3->CC[0] = compare;
  NRF_TIMER3->SHORTS = 1<<8; // event COMPARE[0] -> task STOP
  NRF_TIMER3->EVENTS_COMPARE[0] = 0;
  NRF_PPI->CH[PPI_CAPTURE_CH].EEP = (uint32_t)&NRF_TIMER3->EVENTS_COMPARE[0];
  NRF_PPI->CH[PPI_CAPTURE_CH].TEP = (uint32_t)&NRF_TIMER4->TASKS_CAPTURE[0]; // copie la valeur du compteur dans CC[0]
  NRF_PPI->FORK[PPI_CAPTURE_CH].TEP = (uint32_t)&NRF_TIMER4->TASKS_STOP;
  NRF_PPI->CHEN |= (1<< ((uint32_t)PPI_CAPTURE_CH));
  NRF_PPI->CHENSET |= (1<< ((uint32_t)PPI_CAPTURE_CH));
  
  NRF_TIMER4->TASKS_START = 1;
  NRF_TIMER3->TASKS_START = 1;

  float duree = compare*1.0/16*1e-3*pow(2,prescaler); // en ms
  
  rtos::ThisThread::sleep_for(duree);
  while (NRF_TIMER3->EVENTS_COMPARE[0]==0) {;} // attente de la fin du comptage
  NRF_TIMER3->TASKS_STOP = 1;
  frequence = (NRF_TIMER4->CC[0])/duree*1000;
}

void setup() {
  Serial.begin(115200);
  pinMode(INPUT_PIN,INPUT);
  pwm.period_us(10);
  pwm.write(0.5);
  prescaler = 0; // une incrémentation de TIMER3 toutes les 1/16 microsecondes
  compare = 16000000; // durée de 1.0 s
}

void loop() {
   mesure_frequence();                                                                                                     
   Serial.print(frequence); Serial.println(" Hz");
  
}

    	               

La précision de la mesure de temps est déterminée par l'intervalle de temps d'incrémentation du compteur de TIMER3. On a donc intérêt à choisir un prescaler le plus faible possible. La mesure fonctionne jusqu'à une fréquence de 600kHz, avec une précision de l'ordre de 1/1000.

3. Analyse d'un signal binaire

3.a. Principe

Il s'agit de déterminer la succession temporelle des niveaux bas et hauts. Les fronts montants et descendants doivent être détectés et on doit mesurer l'intervalle de temps entre deux fronts.

sequence.svgFigure pleine page

On choisit de stocker dans un tableau la suite des durées tn en microsecondes, sous la forme d'entiers signés de 32 bits. Le signe est positif si le front qui suit l'intervalle est montant, négatif si le front est descendant. L'intervalle de temps maximal est 231 microsecondes, soit environ 2147 secondes.

Si le signal correspond à une transmission de bits à une fréquence d'horloge connue, on peut déduire de ce tableau la séquence des bits. Dans le cas d'un signal périodique, on peut calculer la période cycle par cycle, puis sa moyenne et son écart-type. Dans certains cas, il s'agit de déterminer seulement l'intervalle de temps entre deux impulsions (mesure de temps).

3.b. Utilisation de l'API MBED

L'entrée analysée doit déclencher une interruption matérielle, aussi bien sur son front montant que sur son front descendant. Le gestionnaire d'interruption lit le temps délivré par mbed::Timer en microsecondes. L'intervalle de temps est calculé grace au temps précédent, qui est mémorisé entre deux appels.

Afin de tester le programme, on génère sur une sortie un signal correspondant à une séquence de bits qui se répète périodiquement. On utilise pour cela un générateur PWM et la classe NRF52_PWM permettant de le programmer (voir Arduino 33 BLE : modulation de largeur d'impulsion).

nrf52_pwm.h

nrf52_pwm.cpp

analyse_binaire_1.ino

#include "Arduino.h"
#include "nrf.h"
#include "nrf52_pwm.h"
#include <math.h>  

#define NSAMPLES 256 // nombre d'intervalles mémorisés
#define NBITS 16// nombre de bits dans le séquence PWM
#define OUTPUT_PIN 3
#define INPUT_PIN 4
#define DECODE_BITS

uint8_t bits[16] = {0,0,1,0,1,1,0,1,0,0,0,1,0,1,1,1};
uint16_t sequence_0[NBITS];
uint16_t sequence_1[NBITS];
uint32_t t1,t2;
int32_t intervalles[NSAMPLES];
uint8_t lecture_bits[NSAMPLES]; // pour le décodage des bits
int16_t indice_t; // indice pour le remplissage du tableau intervalles
uint8_t prescaler = 0; // horloge du PWM à 16 MHz
uint16_t ct = 1600; // 10 kHz fréquence des bits
float T = 100; // période des bits en microsecondes (pour le décodage)
uint16_t refresh = 1; 
uint16_t boucle = 100; // nombre de répétitions périodiques de la séquence de bits

NRF52_PWM pwm;
mbed::InterruptIn inter(digitalPinToPinName(INPUT_PIN));
mbed::Timer temps;

// gestionnaire d'interruption pour les fronts montants
void f_rise() {
   t2 = temps.read_us();
   intervalles[indice_t] = t2-t1;
   t1 = t2;
   indice_t += 1;
   if (indice_t==NSAMPLES) indice_t = 0;  
}
// gestionnaire d'interruption pour les fronts descendants
void f_fall() {
   t2 = temps.read_us();
   intervalles[indice_t] = -(t2-t1);
   t1 = t2;
   indice_t += 1;
   if (indice_t==NSAMPLES) indice_t = 0;
}

// remplissage des deux tableaux pour le PWM
void remplissage_sequences() {
  for (int i=0; i<NBITS; i++) {
    if (bits[i]==1) {
      sequence_0[i] = 0;
      sequence_1[i] = 0;
    }
    else {
      sequence_0[i] = ct;
      sequence_1[i] = ct;
    }
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(OUTPUT_PIN,OUTPUT);
  pinMode(INPUT_PIN,INPUT);
  remplissage_sequences();
  indice_t = 0;
  inter.rise(&f_rise);
  inter.fall(&f_fall);
  temps.start();
  t1 = temps.read_us();
}

void loop() {
   pwm.modulation_une_sortie(OUTPUT_PIN,prescaler,refresh,NBITS,boucle,ct,sequence_0,sequence_1);
   pwm.attendre();
  int j = 0;
  uint16_t nb;
  for (int i=0; i<NSAMPLES; i++) {
   nb = round(abs(intervalles[i])/T); // rapport duree de l'intervalle sur duree d'un bit
#ifdef DECODE_BITS
  if (intervalles[i]>0) for (int k=0; k<nb; k++) Serial.print("0");
  else for (int k=0; k<nb; k++) Serial.print("1");
#else
  Serial.println(intervalles[i]);
#endif 
  }
}
    	         
    	         

Si la macro DECODE_BITS est définie, le programme affiche la suite des bits décodés. Sinon, il affiche la suite des intervalles en microsecondes.

Testé avec un signal externe délivré par un générateur de fonctions délivrant un signal binaire de fréquence 1kHz, l'erreur sur la mesure de la durée entre deux fronts atteint 10 microsecondes. La raison de cette imprécision réside dans l'utilisation d'appels de fonctions pour la mesure du temps, ce qui introduits généralement des délais de l'ordre de la microsecondes. Le programme fonctionne jusqu'à 50kHz, mais l'imprécision des mesures est alors très grande. Voici un échantillon de la séquence des temps renvoyés pour une fréquence de 10kHz (la valeur devrait être proche de 50) :

47
-58
45
-47
57
-46
54
-13
58
-65
33
-50
58
-46
47
-49

3.c. Utilisation d'un Timer

Afin d'améliorer la précision de la mesure du temps, nous allons utiliser un Timer et le piloter directement par l'entrée INPUT_PIN, sans utiliser le processeur (qui servira seulement à lire et à mémoriser la durée mesurée). Cela ne peut se faire que par une programmation directe d'un timer du nRF52840. La configuration du TIMER4 est faite dans la fonction init_timer. Le TIMER4 incrémente son compteur à la fréquence 16MHz divisée par 2prescaler. Le périphérique PGIOTE permet de déclencher un évènement (EVENTS_IN) lorsque l'entrée INPUT_PIN change de niveau (front montant ou descendant). Le PPI (Programmable peripheral interconnect) permet, à partir de cet évènement, de déclencher la tâche TASKS_CAPTURE[0] du TIMER4, qui consiste à recopier la valeur de son compteur dans le registre CC[0], et la tâche TASKS_CLEAR, qui consiste à remettre à zéro le compteur. Si prescaler=0, le registre CC[0] du TIMER4 contient donc le temps écoulé entre le dernier front et le précédent, en 1/16 de microsecondes.

analyse_binaire_2.ino
#include "Arduino.h"
#include "nrf.h"
#include "nrf52_pwm.h"
#include <math.h>  

#define NSAMPLES 256 // nombre d'intervalles mémorisés
#define NBITS 16// nombre de bits dans le séquence PWM
#define OUTPUT_PIN 3
#define INPUT_PIN 4
#define PPI_CAPTURE_CH 10
#define DECODE_BITS

uint8_t bits[16] = {0,0,1,0,1,1,0,1,0,0,0,1,0,1,1,1};
uint16_t sequence_0[NBITS];
uint16_t sequence_1[NBITS];
int32_t intervalles[NSAMPLES];
uint8_t lecture_bits[NSAMPLES]; // pour le décodage des bits
int16_t indice_t; // indice pour le remplissage du tableau intervalles
uint8_t prescaler = 0; // horloge du PWM à 16 MHz
uint16_t ct = 160; // 100 kHz fréquence des bits
float T = 10; // période des bits en microsecondes (pour le décodage)
uint16_t refresh = 1; 
uint16_t boucle = 100; // nombre de répétitions périodiques de la séquence de bits
float timer_dt;

NRF52_PWM pwm;
mbed::InterruptIn inter(digitalPinToPinName(INPUT_PIN));

void init_timer(uint8_t prescaler) {
  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[INPUT_PIN]<<8) | (ports[INPUT_PIN]<<13) | (3<<16); // évènement toggle sur INPUT_PIN
  NRF_TIMER4->TASKS_STOP = 1;
  NRF_TIMER4->MODE = 0; // timer mode
  NRF_TIMER4->BITMODE = 3; // 32 bits
  NRF_TIMER4->PRESCALER = prescaler;
  timer_dt = 1.0/16*pow(2,prescaler);
  NRF_TIMER4->TASKS_CLEAR = 1;
  NRF_PPI->CH[PPI_CAPTURE_CH].EEP = (uint32_t)&NRF_GPIOTE->EVENTS_IN[0];
  NRF_PPI->CH[PPI_CAPTURE_CH].TEP = (uint32_t)&NRF_TIMER4->TASKS_CAPTURE[0];
  NRF_PPI->FORK[PPI_CAPTURE_CH].TEP = (uint32_t)&NRF_TIMER4->TASKS_CLEAR;
  NRF_PPI->CHEN |= (1<< ((uint32_t)PPI_CAPTURE_CH));
  NRF_TIMER4->TASKS_START = 1;
  
}

// gestionnaire d'interruption pour les fronts montants
void f_rise() {
   intervalles[indice_t] = timer_dt*NRF_TIMER4->CC[0];
   indice_t += 1;
   if (indice_t==NSAMPLES) indice_t = 0;
}
// gestionnaire d'interruption pour les fronts descendants
void f_fall() {
   intervalles[indice_t] = -timer_dt*NRF_TIMER4->CC[0];
   indice_t += 1;
   if (indice_t==NSAMPLES) indice_t = 0;
}

// remplissage des deux tableaux pour le PWM
void remplissage_sequences() {
  for (int i=0; i<NBITS; i++) {
    if (bits[i]==1) {
      sequence_0[i] = 0;
      sequence_1[i] = 0;
    }
    else {
      sequence_0[i] = ct;
      sequence_1[i] = ct;
    }
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(OUTPUT_PIN,OUTPUT);
  pinMode(INPUT_PIN,INPUT);
  remplissage_sequences();
  indice_t = 0;
  inter.rise(&f_rise);
  inter.fall(&f_fall);
  init_timer(0);
}

void loop() {
   pwm.modulation_une_sortie(OUTPUT_PIN,prescaler,refresh,NBITS,boucle,ct,sequence_0,sequence_1);
   pwm.attendre();
  int j = 0;
  uint16_t nb;
  for (int i=0; i<NSAMPLES; i++) {
   nb = round(abs(intervalles[i])/T); // rapport duree de l'intervalle sur duree d'un bit
#ifdef DECODE_BITS
  if (intervalles[i]>0) for (int k=0; k<nb; k++) Serial.print("0");
  else for (int k=0; k<nb; k++) Serial.print("1");
#else
  Serial.println(intervalles[i]);
#endif 
  }
}
    	         

Testé avec un signal externe délivré par un générateur de fonctions, ce programme fonctionne très bien jusqu'à une fréquence de 200kHz. Les intervalles entre les fronts sont bien déterminés avec une précision de l'ordre de la microseconde. Voici un échantillon des valeurs de temps obtenues pour une fréquence de 10kHz :

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