L’API des ADC, expliqué aux chatons

Les convertisseurs analogiques numériques, ou CAN en français, ou ADC comme « Analog to Digital Converters » en anglais – terme que j’utiliserai pour ne pas confondre CAN et bus CAN – sont des blocs hardware polyvalents et forts utiles, par exemple pour récupérer de la donnée de capteurs analogiques ou surveiller des tensions d’alimentation de la carte.

Toutes les fonctions pour invoquer les ADC via Zephyr sont documentées dans la page de doc de l’API. Regardons cette API en détail pour pouvoir écrire un exemple simplifié.

Présentation

Pour lire une entrée analogique, il faut:

  • Initialiser les entrées ADC, avec les configurations correctes de pinmux et autres options,
  • Lancer une fonction de lecture, telle que adc_read, adc_read_dt ou adc_read_async.

Ces fonctions nécessitent deux paramètres d’entrées:

  • Un device ADC hardware, donné sous forme d’objet device vers sa description dans le device tree,
  • Un objet de type adc_sequence, qui décrit à la fonction ce qu’il faut récupérer depuis le bloc ADC et comment.
[adc_sequence|
channels (bitmask)|
resolution| 
oversampling|
buffer*|
[adc_sequence_options|
interval_us|
callback*|
extra_samplings]
]

Pour créer cet objet adc_sequence, 2 choix :

  • L’instancier automatiquement depuis les paramètres dans le device tree avec la fonction adc_sequence_init_dt. Celle-ci prends un objet adc_dt_spec en entrée, et remplis les champs « channels », « resolution » et « oversampling » de l’objet adc_sequence en recopiant ce qui est donné dans le device tree sous la forme zephyr,parameter:
&adc0 {
    [...] 
    channel@0 {
        reg = <0>;
        zephyr,gain = "ADC_GAIN_1";
        zephyr,reference = "ADC_REF_INTERNAL";
        zephyr,acquisition-time = <ADC_ACQ_TIME_DEFAULT>;
        zephyr,resolution = <12>;
        zephyr,oversampling = <0>;
    };
};

Description d’un channel dans le device tree

  • Créer un objet adc_sequence manuellement en sélectionnant ses propres paramètres.

Recette de cuisine d’un objet adc_sequence

Lecture de plusieurs canaux d’un même bloc ADC

Pour configurer la lecture simultanée de plusieurs canaux d’un ADC, il faut forcément initialiser l’objet manuellement, car adc_sequence_init_dt configure la lecture d’un seul canal simultané.

Les mêmes paramètres de résolution et d’oversampling seront appliqués à tous les canaux sélectionnés.

Tous les canaux ADC lus doivent appartenir au même bloc ADC hardware.

Les canaux sont sélectionnés dans le paramètre « channels » de l’objet adc_sequence. Ce paramètre est sous forme de bitmask.

Par exemple, pour un ADC avec 3 canaux 0, 3 et 5:

&adc0 {
      channel@0 {
            reg = <0>;
            [...]
      };
      channel@3 {
            reg = <3>;
            [...]
      };
      channel@5 {
            reg = <5>;
            [...]
      };
};

Exemple de multicanaux dans le device tree

Le bitmask est 0b101001 = 0x29.

Nombre d’échantillons

Le nombre d’échantillons à lire sur chaque canal en un même appel de la fonction « read » est déterminé par le paramètre « extra_samplings ».

Ce paramètre correspond au nombre d’échantillons voulus, moins 1.

  • Par défaut, extra_samplings = 0, 1 seul échantillon par canal sera mesuré
  • Pour en sélectionner 8: extra_samplings = 7

Lorsque plusieurs échantillons sont sélectionnés, la fonction « read » s’endort pendant un intervalle de temps de options.interval_us entre deux lectures d’échantillon. Cette valeur peux ne pas être aussi précise ou rapide que ce que permet le hardware, puisqu’elle est basée sur le timer du kernel, autour de 10-50 kHz (précision de 20us-100us). Pour de la qualité audio, par exemple, il sera nécessaire de contourner la limitation et de configurer un déclenchement de lecture par un timer hardware.

Les microphones analogiques ne sont pas encore au programme

ⓘ Obtenir la fréquence du systick

Menuconfig donne les valeurs de fréquence de systick et d’horloge système.

west build -t menuconfig
  • SYS_CLOCK_TICKS_PER_SEC(=10000) System tick frequency (in ticks/second)
  • SYS_CLOCK_HW_CYCLES_PER_SEC (=216000000) System clock’s h/w timer frequency

ⓘ Le sur-échantillonage automatique n’est pas disponible si la lecture simultanée de plusieurs échantillons est sélectionnée.

Buffer!

Toute cette donnée doit être stockée dans un buffer suffisamment spacieux. L’adresse du buffer est donnée à l’objet adc_sequence object sous forme de (void*).

Pour obtenir la taille du buffer nécessaire, multiplier:

  • La taille de stockage d’un échantillon (généralement uint16_t, car la plupart des ADC ont des résolution de 12 ou 16 bits)
  • Le nombre d’échantillons à stocker (1 + extra_samplings)
  • Le nombre de canaux lus simultanément

Par exemple, pour un ADC 12-bits avec 3 canaux sélectionnés et 6 échantillons à lire, le buffer devra être de 36 octets, l’équivalent de 18 uint16_t.

[adc_sequence|
channels (bitmask)|
resolution| 
oversampling|
buffer*|
[adc_sequence_options|
interval_us|
callback*|
extra_samplings]
]

ADC sequence configuration

packetdiag {
  colwidth = 16;
  node_height = 72;

  0-1: Chan0  S0;
  2-3: Chan0  S1;
  4-5: Chan0  S2;
  6-7: Chan0  S3;
  8-9: Chan0  S4;
  10-11: Chan0  S5;
  12-13: Chan3  S0;
  14-15: Chan3  S1;
  16-17: Chan3  S2;
  18-19: Chan3  S3;
  20-21: Chan3  S4;
  22-23: Chan3  S5;
  24-25: Chan5  S0;
  26-27: Chan5  S1;
  28-29: Chan5  S2;
  30-31: Chan5  S3;
  32-33: Chan5  S4;
  34-35: Chan5  S5;
}

Buffer contenant 6 échantillons uint16_t et 3 canaux (0, 3 et 5) (unité = octet)

Lorsque la fonction de lecture s’achève, la donnée est disponible dans le buffer.

Callback customisé

adc_sequence.options contient un champ « callback », vide par défaut. Il permet d’appeler une fonction optionnelle chaque fois qu’un échantillon est lu.

Considérations temporelles

L’utilisation de DMA est sélectionné dans les options du projet, et fonctionne comme par magie. Le DMA ADC n’est pas disponible sur toutes les plate-formes.

Dans mon cas, j’ai une cible STM32, et je peux activer la lecture d’ADC avec DMA dans mon prj.conf.

CONFIG_DMA=y
CONFIG_ADC_STM32_DMA=y

prj.conf

Pour savoir ce que ce changement implique précisément, il faut jeter un oeil à l’implémentation du driver dans drivers/adc/adc_stm32.c.

L’activation du DMA peut être vérifiée avec la commande devmem du shell, ou avec un debuggueur, en mettant un point d’arrêt dans l’implémentation du driver.

Digression sur devmem

Devmem est fort utile pour ceux qui envisagent de débugguer ou de customiser les implémentations de drivers. Il permet de vérifier facilement que le driver a correctement initialisé les différentes options de registre.

Le manuel de référence de notre STM32 nous dit que l’ADC1 utilises le DMA2, Channel 0, Stream 0.

Dans la table des registres, l’adresse de départ du DMA2 est : 0x40026400

Dans la page de détail des registres du DMA, il est dit que le stream X est configuré par le registre DMA_SxCR register, et que le channel auquel ce stream est connecté est donné par les octets 27:25.

DMA_SxCR: Address offset: 0x10 + 0x18 * x, (x = 0 to 7)

L’adresse de DMA2_S0CR est :

0x40026400 + 0x10 + 0x18*0 = 0x40026410

$ devmem 0x40026410
Read value 0x2c10

Cette valeur n’est pas zéro: j’ai des paramètres sélectionnés ici, comme le bit 4 TCIE, ce qui active l’interruption lorsque le transfert est terminé. Let octets 27:25 sont 0b000 : le channel 0 est bel est bien sélectionné. Tout a l’air en ordre.

Cet exemple est entièrement dépendant du SoC et de son architecture, il est nécessaire de se référer à la documentation du fabriquant.


Dans ce cas, adc_read déclenche une séquence. Le processeur dort entre les lectures, puisque le DMA s’occupe de récupérer les échantillons automatiquement. Après lecture de la séquence, la fonction read redonne la main.

seqdiag {
  main  -> dma [label = "adc_read"];
  dma  -> adc [label = "read sample (all chans)"];
  dma <-- adc;
  ... interval_us ...
  dma  -> adc [label = "read sample (all chans)"];
  dma <-- adc;
  ... interval_us ...
  dma  -> adc [label = "read sample (all chans)"];
  dma <-- adc; 
  main <-- dma [label = "return"];
  === ===
  main  -> dma [label = "adc_read"];
  dma  -> adc [label = "read sample (all chans)"];
  dma <-- adc;
  ... interval_us ...
  dma  -> adc [label = "read sample (all chans)"];
  dma <-- adc;
  ... interval_us ...
  dma  -> adc [label = "read sample (all chans)"];
  dma <-- adc; 
  main <-- dma [label = "return"] ;
}

Avec un callback customisé:


seqdiag {
  main  -> dma [label = "adc_read"];
  dma  -> adc [label = "read sample (all chans)"];
  adc --> callback [label = "sequence.options.callback"];
  adc <-- callback;
  dma <-- adc;
  ... interval_us ...
  dma  -> adc [label = "read sample (all chans)"];
  adc --> callback [label = "sequence.options.callback"];
  adc <-- callback;
  dma <-- adc;
  main <-- dma [label = "return"];
}

Pour avoir une lecture continue des ADC, il sera nécessaire de configurer un thread et d’utiliser adc_read_async.

Écrivons un exemple

L’exemple de code est ici: https://github.com/everedero/zephyr/tree/sample_multi_adc, dans le fichier samples/drivers/adc/adc_multichannel/src/main.c

Dans cet exemple, la fonction main lit 32 échantillons depuis 6 canaux différents de l’ADC numéro 1.

Il comporte une fonction callback intitulée « adc_callback » qui imprime le buffer chaque fois qu’un nouvel échantillon est lu, donc toutes les 32 lectures.

Il répète cette séquence de « read » 3 fois, avec un intervalle de 100 msec entre chaque.


seqdiag {
  main  -> dma [label = "adc_read"];
  dma  -> adc [label = "read sample (all chans)"];
  adc --> callback [label = "sequence.options.callback"];
  adc <-- callback;
  dma <-- adc;
  ... interval_us ...
  dma  -> adc [label = "read sample (6 chans)"];
  dma <-- adc;
  ... interval_us ...
  dma  -> adc [label = "read sample (6 chans)"];
  dma <-- adc; 
  ... (x 32) ...;
  main <-- dma [label = "return"];
  === 100 ms ===
  main  -> dma [label = "adc_read"];
  ... (read sample x32) ...;
  main <-- dma [label = "return"] ;
  === 100 ms ===
  main  -> dma [label = "adc_read"];
  ... (read sample x32) ...;
  main <-- dma [label = "return"] ;
}

Conclusion

Échantillonage avec le DMA

Voici une visualisation partielle d’une trace d’un exemple où 32 échantillons sont lus sur 2 canaux. Il est intéressant de voir tout le temps passé dans le processus « idle » pendant que le hardware lis les entrées ADC:

L’interruption « ISR context 1 » montrée ici correspond à « dma_stm32_irq_handler », dans drivers/dma/dma_stm32.c.

ⓘ Le tutoriel pour créer des trace comme celle-ci se trouve ici: https://redero.fr/?p=649&lang=en

Je suis dans un processus idle