Table des matières

Conversion analogique-numérique rapide avec échantillonnage

1. Introduction

Ce document montre comment effectuer des conversions numérique-analogiques échantillonnées avec un Arduino, et transmettre les échantillons à l'ordinateur en flux continu.

L'échantillonnage est fait avec un chronomètre-compteur 16 bits (Timer 1). Le code fourni fonctionne sur l'Arduino Mega (ATmega 2560) et sur l'Arduino UNO (ATmega 328).

L'objectif est d'obtenir une numérisation d'une seule voie la plus rapide possible, en limitant à une résolution de 8 bits par échantillons. Pour la numérisation multivoies en 10 bits, voir Conversion analogique-numérique multivoies avec échantillonnage.

2. Échantillonnage

L'échantillonnage est fait avec le Timer 1. Pour une présentation des Timers, voir Génération d'impulsions et d'interruptions périodiques.

Le Timer 1 comporte un compteur 16 bits (registre TCNT1). Nous allons l'utiliser dans le mode CTC (clear timer on compare match), avec une valeur maximale fixée par le registre OCR1A. Le compteur est incrémenté d'une unité à chaque top d'horloge jusqu'à atteindre la valeur fixée par OCR1A. La période d'échantillonnage est égale à la période de l'horloge multipliée par la valeur maximale. On utilisera aussi le registre OCR1B, dont la valeur sera fixée à la moitié de OCR1A. Lorsque TCNT1=OCR1B, une interruption sera déclenchée. La conversion analogique-numérique sera aussi déclenchée par cette condition. La figure suivante montre l'évolution au cours du temps du compteur :

timer.svgFigure pleine page

La configuration du Timer 1 dans ce mode se fait avec les bits WGM13, WGM12, WGM11 et WGM10 (Waveform Generation Mode). Les deux premiers sont les bits 4 et 3 du registre TCCR1B; les deux derniers sont les bits 1 et 0 du registre TCCR1A. Dans le cas présent, il faut mettre à 1 le bit WGM12.

TCCR1A = 0;
TCCR1B = 0;
TCCR1B |= (1 << WGM12);
              

L'horloge utilisée par le Timer est l'horloge principale de l'Arduino, à laquelle on peut fair subir une division de fréquence. Le choix de l'horloge se fait sur les bits CS12, CS11, CS10 du registre TCCR1B :

Le tableau suivant contient les facteurs de division, l'indice du tableau correspondant au 3 bits ci-dessus:

uint16_t diviseur[6] = {0,1,8,64,256,1024};
               

Supposons que la période d'échantillonnage en microsecondes soit dans une variable 32 bits period. Voici comment se fait le calcul de la valeur de OCR1A et du sélecteur d'horloge :

uint32_t top = (F_CPU/1000000*period);
int clock = 1;
while ((top>0xFFFF)&&(clock<5)) {
    clock++;
    top = (F_CPU/1000000*period/diviseur[clock]);
}
OCR1A = top;
                

La macro F_CPU contient la fréquence de l'Arduino en Hz (16 MHz pour l'Arduino MEGA). On attribue au registre OCR1B la moitié de OCR1A (toute autre valeur inférieure à OCR1A conviendrait) :

OCR1B = top >> 1;
                 

Pour qu'une interruption se déclenche lorsque le compteur est égal à OCR1B, il faut activer le bit correspondant dans le registre TIMSK1 (Timer interrupt mask) :

TIMSK1 = (1 << OCIE1B);
                  

Il faut aussi penser à activer les interruptions avec :

sei();
                  

Pour déclencher le Timer, il faut écrire les 3 bits définissant l'horloge :

TCCR1B |= clock;
                  

Pour le stopper, il faut mettre ces 3 bits à zéro :

TCCR1B &= ~0x7;
                  

3. Convertisseur analogique-numérique

Les arduino Mega et Uno comportent un convertisseur analogique-numérique 10 bits (ADC). La configuration de l'ADC se fait avec 3 registres 8 bits : ADMUX, ADCSRA et ADCSRB.

Le convertisseur est associé à un multiplexeur-amplificateur, qui permet de choisir l'entrée analogique à utiliser, ou les deux entrées dans le cas d'une mesure différentielle. Il est aussi possible d'appliquer un gain au signal analogique avant la conversion. Le multiplexeur se configure avec un code 6 bits. Pour connaître ce code, il faut consulter la documentation du microcontrôleur. Pour le ATmega328 et ATmega2560, les codes 0 à 7 correspondent aux bornes A0 à A7 utilisées en entrée simple. Pour donner un exemple sur l'ATmega2560 (Arduino Mega), le code binaire 010000 configure les entrées A0 et A1 en mode différentielle.

Les 5 premiers bits du code sont les 5 premiers bits du registre ADMUX. Le dernier bit du code (poids fort) est le bit 2 du registre ADCSRB. Voici comment se fait la configuration du multiplexage :

uint8_t multiplex = 0;
ADMUX = 0;
ADCSRB = 0;
ADMUX |= multiplex;
ADCSRB |= (multiplex & 0b00100000) >> 2;
               

La tension de référence de l'ADC est choisie par les deux bits REFS0 et REFS1 du registre ADMUX. On choisit ici AVCC :

ADMUX |= (1 << REFS0);
               

La conversion se fait en principe en 10 bits. Le résultat d'une conversion est dans les deux registres 8 bits ADCH (4 bits de poids fort) et ADCL (8 bits de poids faible). Il faut lire d'abord le registre ADCH. Pour obtenir la vitesse de numérisation la plus grande possible, on fera une numérisation en 8 bits. Dans ce cas, on a intérêt à décaler les bits vers la gauche pour que la valeur 8 bits se trouve entièrement dans le registre ADCH. Ce réglage est obtenu par :

ADMUX |= (1 << ADLAR);
                  

Dans le registre ADSRA, on doit activer l'ADC, l'auto-déclenchement et les interruptions :

ADCSRA |= (1 << ADEN); // enable ADC
ADCSRA |= (1 << ADATE); // Auto Trigger Enable
ADCSRA |= (1 << ADIE); // interrupt enable
                  

Dans le cas de l'auto-déclenchement (auto au sens non logiciel), la source du déclenchement doit être sélectionnée avec les bits ADTS2, ADTS1 et ADTS0 du registre ADCSRB. On choisit de déclencher la conversion lorsque le compteur du Timer 1 (TCNT1) atteint la valeur de OCR1B.

ADCSRB |= (1 << ADTS2)|(1 << ADTS0);// trigger source : timer 1 comp B
                  

Le dernier paramètre à régler est la fréquence de l'horloge utilisée par l'ADC, qui est la fréquence principale divisée par un facteur 2,4,8,16,32,64,128. Les trois premiers bits ADPS1, ADPS1 et ADPS0 du registre ADCSRA définissent le code correspondant (1,2,3,4,5,6,7). Voici la configuration de ce code :

uint8_t prescaler = 4;
ADCSRA |= prescaler ;
                    

La valeur utilisée par la fonction analogRead est 7, soit un préfacteur de 128. Cette valeur conduit à une résolution effective de 10 bits mais la conversion est trop lente pour un échantillonnage aux fréquences audio (de l'ordre de 40 kHz). On sera donc amené à choisir un préfacteur plus petit pour accélérer la conversion, sachant qu'il y aura une perte de résolution, non gênante puisqu'on lira seulement les 8 bits de poids fort.

La conversion est effectuée automatiquement lorsque TCNT1=OCRB1. Lorsque la conversion est terminée, une interruption est générée par l'ADC (bit ADIE activé ci-dessus). La lecture et la mémorisation de la valeur doit se faire dans la fonction d'interruption suivante :

ISR(ADC_vect) { // ADC interrupt handler
    uint8_t x = ADCH; // lecture 8 bits
}
                       

4. Mémorisation et transmission des échantillons

Les échantillons obtenus dans la fonction ADC_vect doivent être mémorisés dans un tampon. Nous choisissons un tampon de 256 éléments. Lorsqu'on utilise le tampon, par exemple pour le transmettre à l'ordinateur par la liaison série, il ne faut pas que l'ADC soit en train d'écrire dessus. Pour résoudre ce problème, on utilise la technique des tampons multiples. Voici comment se fait le déclaration d'un tableau à 4 tampons et des indices associés :

#define NBUF 0x4
#define NBUF_MASK 0x3 // (NBUF-1)
#define BUF_SIZE 0x100
#define BUF_MASK 0xFF // (BUF_SIZE-1)
volatile uint8_t buf[NBUF][BUF_SIZE];
volatile uint8_t compteur_buf,compteur_buf_transmis;
volatile uint16_t indice_buf;
                 

Voici comment se fait l'écriture dans le tampon en cours :

ISR(ADC_vect) {
    buf[compteur_buf][indice_buf] = ADCH;
    indice_buf++;
    if (indice_buf == BUF_SIZE) {
       indice_buf = 0;
       compteur_buf = (compteur_buf+1)&NBUF_MASK;
     } 
}
                 

Lorsque compteur_buf_transmis est différent de compteur_buf, le tampon correspondant est prêt pour la transmission à l'ordinateur, qui se fait par :

Serial.write((uint8_t *)buf[compteur_buf_transmis],BUF_SIZE);
compteur_buf_transmis=(compteur_buf_transmis+1)&NBUF_MASK;
                  

Les données sont donc transmises par bloc de 256 échantillons.

5. Programme Arduino

Voici le programme arduino complet, pour l'Arduino MEGA.

arduino-ADC-rapide.ino
#include "Arduino.h"
#define SET_ACQUISITION 100
#define STOP_ACQUISITION 101
#define NBUF 0x4
#define NBUF_MASK 0x3 // (NBUF-1)
#define BUF_SIZE 0x100
#define BUF_MASK 0xFF // (BUF_SIZE-1)
volatile uint8_t buf[NBUF][BUF_SIZE];
volatile uint8_t compteur_buf,compteur_buf_transmis;
volatile uint16_t indice_buf;
uint16_t diviseur[6] = {0,1,8,64,256,1024};
uint32_t nblocs;
uint8_t sans_fin;
uint8_t flag;
            

La fonction suivante initialise et déclenche le timer avec la période donnée :

void timer1_init(uint32_t period) {
    TCCR1A = 0;
    TCCR1B = 0;
    TCCR1B |= (1 << WGM12);
    uint32_t top = (F_CPU/1000000*period);
    int clock = 1;
    while ((top>0xFFFF)&&(clock<5)) {
          clock++;
          top = (F_CPU/1000000*period/diviseur[clock]);
      }
    OCR1A = top;
    OCR1B = top >> 1;
    TIMSK1 = (1 << OCIE1B);
    TCCR1B |= clock;
    
}
            

La fonction suivante initialise l'ADC :

void adc_init(uint8_t multiplex, uint8_t prescaler) {
  ADMUX = (1 << REFS0);
  ADMUX |= multiplex & 0b00011111;
  ADMUX |= (1 << ADLAR); //left adjust
  ADCSRA = 0;
  ADCSRA |= (1 << ADEN); // enable ADC
  ADCSRA |= (1 << ADATE); // Auto Trigger Enable
  ADCSRA |= (1 << ADIE); // interrupt enable
  ADCSRA |= prescaler ;
  ADCSRB = 0;
  ADCSRB |= (multiplex & 0b00100000) >> 2;
  ADCSRB |= (1 << ADTS2)|(1 << ADTS0);// trigger source : timer 1 comp B
   
}
            

Voici la fonction d'interruption appelée lorsque la conversion est terminée :

ISR(ADC_vect) {
    buf[compteur_buf][indice_buf] = ADCH;
    indice_buf++;
    if (indice_buf == BUF_SIZE) {
       indice_buf = 0;
       compteur_buf = (compteur_buf+1)&NBUF_MASK;
     }  
}
            

Il y a aussi une interruption COMPB générée par le Timer (curieusement, il faut exécuter cette interruption pour que l'ADC fonctionne). Dans la fonction d'interruption, on fait alterner la sortie PD3, ce qui permettra de contrôler le fonctionnement de l'échantillonneur à l'oscilloscope. Cette sortie est reliée à la borne 3 sur l'Arduino UNO (voir ATmega168/328P-Arduino Pin Mapping), à la borne 18 sur l'arduino MEGA (voir ATmega2560-Arduino Pin Mapping).

ISR(TIMER1_COMPB_vect) {
    if (flag==0) {
         flag = 1;
         PORTD &= ~(1<<PORTD3); // sortie 18 sur Arduino MEGA, 3 sur UNO 
       }
     else {
        flag = 0;
        PORTD |= (1<<PORTD3);   
     }   
}
              

Voici la fonction qui stoppe le timer :

void stop_acquisition() {
   TCCR1B &= ~0x7; 
}
              

La fonction d'initialisation établit la communication série avec l'ordinateur

void setup() {
  char c;
  Serial.begin(500000);
  Serial.setTimeout(0);
  c = 0;
  Serial.write(c);
  c = 255;
  Serial.write(c);
  c = 0;
  Serial.write(c);
  compteur_buf = compteur_buf_transmis = 0;
  indice_buf = 0;
  DDRD |= 1 << PORTD3; // PD3 en sortie
  flag = 0;
}

              

La fonction suivante lit les paramètres envoyés par l'ordinateur pour la configuration de l'acquisition et la démarre. Les données transmises sont :

void lecture_acquisition() {
  uint32_t c1,c2,c3,c4;
  uint8_t multiplex,prescaler;
  uint32_t period;
  while (Serial.available()<2) {};
  multiplex = Serial.read();
  prescaler = Serial.read();
  while (Serial.available()<4) {};
  c1 = Serial.read();
  c2 = Serial.read();
  c3 = Serial.read();
  c4 = Serial.read();
  period = ((c1 << 24) | (c2 << 16) | (c3 << 8) | c4);
  while (Serial.available()<4) {};
  c1 = Serial.read();
  c2 = Serial.read();
  c3 = Serial.read();
  c4 = Serial.read();
  nblocs = ((c1 << 24) | (c2 << 16) | (c3 << 8) | c4);
  if (nblocs==0) {
        sans_fin = 1;
        nblocs = 1;
   }
   else sans_fin = 0;
   compteur_buf=compteur_buf_transmis=0;
   indice_buf = 0;
  cli();
  timer1_init(period);
  adc_init(multiplex,prescaler);
  sei();
}
              

La fonction suivante lit le port série (de manière non bloquante). En cas de présence d'une des deux commandes, elle exécute l'action correspondante.

void lecture_serie() {
   char com;
   if (Serial.available()>0) {
        com = Serial.read();
        if (com==SET_ACQUISITION) lecture_acquisition();
        if (com==STOP_ACQUISITION) stop_acquisition();
   }
}
              

Voici la fonction loop, qui transmet les tampons sur le port série.

void loop() {
  if ((compteur_buf_transmis!=compteur_buf)&&(nblocs>0)) {
      Serial.write((uint8_t *)buf[compteur_buf_transmis],BUF_SIZE);
      compteur_buf_transmis=(compteur_buf_transmis+1)&NBUF_MASK;
      if (sans_fin==0) {
            nblocs--;
            if (nblocs==0) stop_acquisition();
      }
  }
  else lecture_serie();
}
              

6. Programme Python

Le programme suivant (pour Python 3) effectue la configuration de l'acquisition et la lecture des blocs d'échantillons.

arduinoADCrapide.py
# -*- coding: utf-8 -*-
import serial
import numpy
import time
from matplotlib.pyplot import *
import numpy.fft
import threading

class Arduino():
    def __init__(self,port):
        self.ser = serial.Serial(port,baudrate=500000)
        c_recu = self.ser.read(1)
        while ord(c_recu)!=0:
            c_recu = self.ser.read(1)
        c_recu = self.ser.read(1)
        while ord(c_recu)!=255:
            c_recu = self.ser.read(1)
        c_recu = self.ser.read(1)
        while ord(c_recu)!=0:
            c_recu = self.ser.read(1)
        self.SET_ACQUISITION = 100
        self.STOP_ACQUISITION = 101
        self.TAILLE_BLOC = 256

    def close(self):
        self.ser.close()

    def write_int16(self,v):
        v = numpy.int16(v)
        char1 = int((v & 0xFF00) >> 8)
        char2 = int((v & 0x00FF))
        self.ser.write((char1).to_bytes(1,byteorder='big'))
        self.ser.write((char2).to_bytes(1,byteorder='big'))
        
    def write_int32(self,v):
        v = numpy.int32(v)
        char1 = int((v & 0xFF000000) >> 24)
        char2 = int((v & 0x00FF0000) >> 16)
        char3 = int((v & 0x0000FF00) >> 8)
        char4 = int((v & 0x000000FF))
        self.ser.write((char1).to_bytes(1,byteorder='big'))
        self.ser.write((char2).to_bytes(1,byteorder='big'))
        self.ser.write((char3).to_bytes(1,byteorder='big'))
        self.ser.write((char4).to_bytes(1,byteorder='big'))

    def lancer_acquisition(self,multiplex,fechant,nblocs,prescaler=4):
        period = int(1e6*1.0/fechant)
        self.nblocs = nblocs
        self.ser.write((self.SET_ACQUISITION).to_bytes(1,byteorder='big'))
        self.ser.write((multiplex).to_bytes(1,byteorder='big'))
        self.ser.write((prescaler).to_bytes(1,byteorder='big'))
        self.write_int32(period)
        self.write_int32(nblocs)

    def stopper_acquisition(self):
        self.ser.write((self.STOP_ACQUISITION).to_bytes(1,byteorder='big'))

    def lecture(self):
        buf = self.ser.read(self.TAILLE_BLOC)
        data = numpy.zeros(self.TAILLE_BLOC,dtype=numpy.float32)
        for i in range(self.TAILLE_BLOC):
            data[i]=buf[i]*1.0
        return data
            

La fonction suivante effectue l'acquisition de 40 blocs à la fréquence d'échantillonnage de 50 kHz. L'arduino est branché sur le port COM5. Les échantillons sont tracés en fonction du temps et un spectre est tracé, ce qui permet de vérifier le bon fonctionnement de l'échantillonnage.

def test():
    ard = Arduino("COM5")
    nblocs = 40
    fechant = 50000.0
    prescaler = 4
    N = ard.TAILLE_BLOC
    ne = N*nblocs
    x = numpy.zeros(ne,dtype=numpy.float32)
    t = numpy.arange(ne,dtype=numpy.float32)*1.0/fechant
    
    ard.lancer_acquisition(0,fechant,nblocs,prescaler)
    i = 0
    for b in range(nblocs):
        data = ard.lecture()
        x[i:i+N] = data
        i += N
    time.sleep(1)
    ard.close()
    figure()
    plot(t,x)
    xlabel("t (s)")
    axis([0,t[-1],0,256])
    grid()
    figure()
    spectre = numpy.absolute(numpy.fft.fft(x))/(ne)
    f = numpy.arange(ne,dtype=numpy.float32)*fechant/ne
    plot(f,spectre)
    xlabel("f (Hz)")
    grid()
    show()
            

Les tests montrent que la fréquence maximale est de 52 kHz aussi bien pour l'Arduino Duemilanova (équivalent de l'Arduino UNO), que pour l'Arduino MEGA. Il faut pour cela utiliser un préfacteur (prescaler) de 4. Une valeur plus petite n'apporte pas d'amélioration. À la fréquence de 50 kHz, le débit de données moyen est de 400 kbits par secondes. La fréquence maximale d'opération du convertisseur est 76 kHz. Dans le cas présent, la limite vient probablement du temps d'exécution de la fonction ISR(TIMER1_COMPB_vect).

Les valeurs obtenues sont comprise entre 0 et 255. La valeur 0 correspond à 0 V. La valeur 255 correspond environ à 5 V. Un étalonnage est nécessaire pour déterminer précisément la tension (au bit près). Il peut être nécessaire d'acquérir des tensions alternatives. Le circuit suivant, alimenté par l'arduino, permet d'appliquer un décalage à un signal bipolaire, de manière à ramener le zéro à 2,5 V :

interfaceArduinoStandard-ADC.svgFigure pleine page

Lorsque l'acquisition est lancée, il faut faire une lecture asynchrone des données envoyées par l'arduino. On se fixe comme objectif de traiter ou d'afficher le signal par paquets, chacun étant constitué d'un certain nombre de blocs. Les blocs sont les unités transmises par l'arduino, qui contiennent ici 256 échantillons. Nous allons faire la lecture des données envoyées par l'arduino sur un fil d'exécution. On doit pour cela définir une classe qui hérite de threading.Thread. Voici le constructeur de cette classe :

class AcquisitionThread(threading.Thread): 
    def __init__(self,arduino,multiplex,fechant,nblocs,prescaler=4):
        threading.Thread.__init__(self)
        self.arduino = arduino
        self.multiplex = multiplex
        self.prescaler = prescaler
        self.fechant = fechant
        self.running = False
        self.nblocs = nblocs # nombre de blocs dans un paquet
        self.npaquets = 8 # 1 paquet = nblocs*arduino.TAILLE_BLOC
        self.taille_bloc = arduino.TAILLE_BLOC
        self.buf = numpy.zeros((self.npaquets,self.nblocs*arduino.TAILLE_BLOC))
        self.compteur_paquets = 0
        self.compteur_paquets_lus = 0
            

On crée un tampon circulaire qui contient 8 paquets. On pourra ainsi lire un paquet pour le traiter alors que le Thread est en train d'écrire un autre paquet. On a aussi défini self.compteur_paquets un indice pour le prochain paquet à récupérer de l'arduino et à placer dans le tampon, et self.compteur_paquets un indice pour le prochain paquet à lire dans le tampon.

La fonction run est exécutée lorsqu'on lance le Thread. Elle consiste en une boucle sans fin qui lit les données provenant de l'arduino et les stocke dans le tampon circulaire. Cette boucle incrémente self.compteur_paquets.

    def run(self):
        self.arduino.lancer_acquisition(self.multiplex,self.fechant,0,self.prescaler) # acquisition sans fin
        self.running = True
        indice_bloc = 0
        while self.running:
            i = indice_bloc*self.taille_bloc
            j = i+self.taille_bloc
            self.buf[self.compteur_paquets,i:j] = self.arduino.lecture()
            indice_bloc += 1
            if indice_bloc==self.nblocs:
                indice_bloc = 0
                self.compteur_paquets += 1
                if self.compteur_paquets==self.npaquets:
                    self.compteur_paquets = 0
            

La fonction suivante permet de stopper l'acquisition :

    def stop(self):
        self.running = False
        self.join()
        self.arduino.stopper_acquisition()
            

L'appel self.join() permet d'attendre la sortie de la fonction run.

Voici la fonction qui permet de lire un paquet dans le tampon :

    def paquet(self):
        if self.compteur_paquets==self.compteur_paquets_lus:
            return 0
        P = self.buf[self.compteur_paquets_lus,:]
        self.compteur_paquets_lus += 1
        if self.compteur_paquets_lus==self.npaquets:
            self.compteur_paquets_lus = 0
        return P
            

7. Test avec tracé du signal

Le programme de test suivant effectue une acquisition avec un tracé du signal dans une fenêtre matplotlib gérée par une animation.

testAcquisitionAnimate.py
import numpy
from matplotlib.pyplot import *
import matplotlib.animation as animation
from arduinoADCrapide import *

ard = Arduino("COM5")
fechant = 40000
n = 5
nechant = ard.TAILLE_BLOC*n
delai = nechant*1.0/fechant
t = numpy.arange(nechant)*1.0/fechant
fig,ax = subplots()
line0, = ax.plot(t,numpy.zeros(nechant))
ax.axis([0,t.max(),0,256])
ax.grid()

voies = [0,1]
gains = [1,1]
multiplex=0
prescaler = 4
acquisition = AcquisitionThread(ard,multiplex,fechant,n,prescaler)
acquisition.start()

def animate(i):
    global line0,acquisition
    data = acquisition.paquet()
    if isinstance(data,int)==False:
        line0.set_ydata(data)

ani = animation.FuncAnimation(fig,animate,100,interval=delai*1000)
show()
acquisition.stop()
ard.close()
             

8. Conclusion

Nous obtenons un système de numérisation échantillonnée fonctionnant jusqu'à une fréquence de 50 kHz en 8 bits par échantillon. Avec une carte Arduino UNO et un circuit d'interface pour décaler les signaux, le coût total n'excède pas 30 euros. Bien sûr, l'Arduino apporte bien plus qu'un simple système d'acquisition, puisque le code présenté ici peut être adapté afin d'être utilisé dans une application embarquée. On peut par exemple traiter le signal délivré par un capteur (analyse ou filtrage numérique) afin de déclencher différentes actions. Nous avons aussi réalisé un convertisseur plus rapide et plus précis (500 kHz et 12 bits) avec l'Arduino Due (processeur ARM 32 bits) : Conversion analogique-numérique et acquisition sur Arduino Due.

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