(Page mise à jour le 5/12/2021)
Ce sujet est abordé sur un grand nombre de forums Internet et de sites Web, bien souvent sans apporter de solution simple, claire et efficace. Cette problématique nous concerne en avionique, où des modules indépendants doivent communiquer et échanger des données numériques. C’est par exemple le cas dans notre EFIS, où l’AHRS et le magnétomètre distant doivent communiquer avec le module principal. La transmission série de caractères ou de chaines de caractères est très simple, mais l’envoi et la réception de variables numériques codées sur plusieurs octets est moins intuitive, en particulier pour les nombres décimaux. Il y a plusieurs solutions possibles, le but de cette page est d’exposer les principales, avec leurs avantages et leurs inconvénients. La meilleure solution devra répondre à ce cahier des charges : être efficiente, avoir le code le plus compact possible, être utilisable quel que soit le type de variable numérique, quel que soit le type de transmission série (UART ou CAN bus) et ne pas entraîner de perte de précision des données numériques transmises.
La conversion en chaines de caractères
Il faut le dire d’emblée, c’est la pire des solutions, elle est pourtant très souvent évoquée sur les forums. Soit par exemple la variable x, de type float, et de valeur 3.14159274. Par une liaison série UART, la fonction Arduino bien connue Serial.print(x,8) va certes parfaitement transmettre la chaine de 10 caractères « 3.14159274 » sur le terminal série, ce qui pourrait sembler un bon début. Mais la voie série a transmis 10 octets, alors que la variable float d’origine n’est codée que sur 4 octets. Donc ce n’est pas très efficient. Les problèmes vont s’accumuler lors de la réception. Il va falloir charger les caractères reçus les uns à la suite des autres, soit dans un objet de type String, soit dans un tableau de char (qu’il faudra surdimensionner, pour s’adapter aux nombres composés de beaucoup de chiffres). On pourra ensuite appliquer assez simplement la fonction toFloat() à l’objet String, ou la fonction atof() au tableau de char, fonctions qui se chargent de la conversion vers une variable de type float. Mais ces deux fonctions arrondissent à 2 chiffres après la virgule. Malgré la débauche de ressources consommées, le résultat est donc décevant, la perte de précision est importante. Rappelons au passage que l’usage intensif et répété d’objets de type String est déconseillé car pouvant entraîner des plantages aléatoires. Les tableaux de char doivent toujours être préférés. Enfin, pour enterrer définitivement cette solution, notons qu’elle n’est absolument pas adaptée au CAN bus, dont les messages ont une longueur maximale de 8 caractères.
Le décodage des octets constitutifs
Le but est de décomposer les variables en leurs octets constitutifs, afin de transmettre ensuite ces octets les uns après les autres. Puis faire l’inverse à la réception. La méthode reste relativement simple pour les entiers, mais devient plus ardue pour les valeurs décimales, dont le format binaire de stockage en mémoire est nettement plus complexe.
Prenons l’exemple d’une variable a, de type entier signé 16 bits (int16_t), et de valeur hexadécimale 0xF3E2 (-3102 en décimal). Cette variable est constituée des 2 octets de valeurs 0xF3 et 0xE2. Pour calculer ces deux octets b et c, afin de pouvoir les transmettre, il faut faire appel aux opérateurs logiques bit à bit et aux opérateurs de décalage, ce qui est plutôt simple. Cela donne :
int16_t a = 0xF3E2
char b = 0xF3E2 & 0xFF = 0xE2 (le & 0xFF est facultatif, car b est de type char)
char c = 0xF3E2 >> 8 = 0xF3
Il est simple d’envoyer ensuite ces deux octets par une liaison série, UART ou CAN. Lors de la réception, il est également assez simple de reconstituer l’entier d’origine, en faisant bien sûr attention à l’ordre d’envoi et de réception des octets.
int16_t a = (c << 8) | b
Pour un entier 32 bits, la procédure est exactement la même, avec quelques lignes de code supplémentaires.
Les choses sont bien différentes pour les nombres de type float. Ils sont exprimés comme le produit d’une mantisse et d’une puissance de 2, sous la forme suivante :
± mantisse x 2exposant
Ils sont codés en mémoire sur 4 octets successifs, donc sur 32 bits, avec 1 bit de signe, suivi par 8 bits d’exposant, et enfin 23 bits de mantisse (soit environ 7 chiffres significatifs). Dans ces conditions, il n’est pas possible d’utiliser les opérateurs logiques et les opérateurs de décalage comme pour les entiers. Pour les floats, on peut en fait difficilement s’en sortir sans accéder explicitement à leur stockage en mémoire, on verra que c’est la 3ème solution, voir ci-dessous.
Cependant, puisqu’on peut facilement décomposer et transmettre les octets constitutifs d’un entier, on pourrait imaginer convertir les nombres décimaux en nombres entiers, en les multipliant par une puissance de 10. Par exemple, pour transmettre le nombre décimal 12,345 il faudrait le multiplier par 103, pour obtenir le nombre entier 12345, facile à décomposer, puis à transmettre, puis à reconstituer à la réception, et enfin à diviser par 103 pour retrouver le nombre d’origine. Beaucoup de lignes de code. Qu’en serait-il pour un nombre de type double (64 bits sur les systèmes ARM), comme 12,3456789 ? Il faudrait soit le multiplier par 107, et obtenir ainsi un entier sur 64 bits (encore plus de lignes de code), soit accepter de perdre les dernières décimales.
Donc cette technique de décodage en octets constitutifs est peu satisfaisante, assez lourde sur le plan du code, même si elle pourrait à la rigueur être utilisée pour les entiers. Mais surtout, à quoi bon ? En effet, la 3ème solution s’applique aussi bien au entiers qu’aux décimaux, et répond en tous points au cahier des charges indiqué en introduction.
La mémoire et les pointeurs
On a déjà vu plus haut que les nombres décimaux de type float sont codés en mémoire sur 4 octets successifs, soit 32 bits. Il en est de même pour les entiers de type int et unsigned int, également codés sur 32 bits sur les systèmes ARM. Les types long long et double sont codés sur 8 octets (64 bits). La solution idéale pour transmettre un nombre, quel qu’il soit, est en fait de transmettre le contenu des 4 ou 8 « cases » qu’il occupe en mémoire. Donc 4 ou 8 octets à transmettre les uns à la suite des autres. Pour la réception, il faut déclarer une variable du même type, qui occupera donc également 4 ou 8 octets en mémoire, puis placer (dans le bon ordre) les octets reçus dans les cases mémoire réservées pour cette variable. Laquelle prendra alors la valeur qui a été transmise.
Le langage C/C++ utilisé par nos microcontrôleurs Arduino et Teensy permet de faire cela facilement, car il permet d’accéder à la mémoire grâce aux pointeurs. Un rappel préalable sur la mémoire et les pointeurs est nécessaire.
La mémoire est constituée d’emplacements élémentaires de 8 bits, soit un octet, on parle souvent de case mémoire. Toutes ces cases peuvent être numérotées les unes à la suite des autres, on parle d’adresse mémoire, ou de référence. Comme leur nom l’indique, les pointeurs « pointent » sur un emplacement mémoire, ils indiquent donc une adresse particulière. Les pointeurs se comportent comme des variables un peu particulières, ils ont un nom, ils doivent être déclarés, et surtout, ce qui est fondamental, ils sont typés : un pointeur de type char ne pointe que sur une seule case mémoire. Un pointeur de type int16_t pointe sur une case mémoire et celle qui suit, un pointeur de type float pointe sur une case et les 3 suivantes. L’inconvénient des pointeurs réside essentiellement dans leur syntaxe qui, bien que très logique, est souvent assez déroutante quand on les découvre. On va en expliquer les principes grâce à de nombreux exemples.
Voici un exemple de syntaxe de déclarations de pointeurs. L’astérisque entre le type et le nom de la variable indique que cette dernière est un pointeur.
char * pPointeurN; // pPointeurN est un pointeur de type char
int * pPointeurM;
Dans l’exemple ci-dessus, pPointeurN est du type « Pointeur vers char », et pPointeurM est du type « Pointeur vers entier ». Au stade de la simple déclaration, un pointeur ne pointe sur rien. Tout comme une variable déclarée qui n’a pas encore de valeur.
Pour récupérer dans une variable la valeur stockée à l’adresse indiquée par un pointeur, la syntaxe est la suivante :
char N = *pPointeurN;
int M = *pPointeurM;
On fait précéder le nom du pointeur d’une astérisque. Il faut naturellement que le type de la variable soit le même que le type du pointeur. Et pour que cela ait un sens, il faut que le pointeur ait été initialisé, c’est à dire qu’il pointe sur quelque chose. On va voir un peu plus bas comment initialiser un pointeur.
Donc *pPointeurN est une valeur et pPointeurN est une adresse mémoire (ou référence comme on a vu plus haut). Le caractère « * » est appelé opérateur de déréférencement.
Pour obtenir l’adresse à laquelle est stockée une variable, on fait précéder son nom du caractère « & », qui est appelé opérateur de référencement. Ainsi, &N et pPointeurN désignent la même adresse dans les exemples ci-dessus.
On peut déclarer un pointeur en lui donnant une valeur :
char N;
char *pPointeurN = &N;
int M;
int *pPointeurM = &M;
On peut aussi affecter une adresse à un pointeur préalablement déclaré :
pPointeurN = &N; //sous réserve que les deux arguments soient du même type
On peut modifier un pointeur, en lui ajoutant ou en lui retranchant une valeur entière. Mais attention : on avance ou recule d’un nombre de cases mémoires égal à la taille du type du pointeur. Le résultat est un pointeur de même type que le pointeur de départ. Il faut faire attention, avec ce genre d’opération, à ne pas sortir du bloc mémoire que l’on souhaite, car le compilateur n’effectuera aucun contrôle. Conséquence de ceci, si on veut se déplacer d’une seule case mémoire à l’aide de l’incrémentation d’un pointeur, ce dernier doit obligatoirement être d’un type codé sur un seul octet. Pour illustrer ceci, voir l’exemple de code ci-dessous.
char N;
char * pPointeurN;
pPointeurN = &N;
pPointeurN ++; // avance d'une seule case en mémoire
uint32_t M;
uint32_t * pPointeurM = &M;
pPointeurM ++; // avance de 4 cases mémoire
On pourrait alors imaginer avoir accès aux 4 emplacements mémoire contigus de la variable M de type uint32_t de l’exemple précédent, en utilisant le pointeur de type char pPointeurN, comme ceci:
pPointeurN = &M;
Mais c’est interdit en C/C++. Si on essaye malgré tout de le faire, le compilateur proteste énergiquement, avec le message suivant : »cannot convert ‘uint32_t*’ to ‘char*’ in assignment« . On va voir ci-dessous qu’il y a une autre solution.
Autre notion fondamentale concernant les pointeurs : le nom d’un tableau est un pointeur. Si par exemple on a déclaré :
char liste[4] = {'A', 'B', 'C', 'D'};
alors les notations liste[1] et *(liste+1) sont équivalentes, retournant le 2ème caractère du tableau, soit la lettre B. liste est un pointeur de type char qui contient l’adresse du premier octet du tableau, d’index 0, liste+2 contient l’adresse du 3ème octet, *(liste+2) retourne ‘C’… etc.
Dernière notion indispensable à connaître pour notre problématique de transmission série de valeurs numériques, celle de conversion de type forcée : il est possible de convertir explicitement une valeur (et non une variable) en un type quelconque en forçant la transformation. La syntaxe est :
(type) expression
Exemple avec des variables ordinaires :
char A = 3;
int B = 2;
float C;
C = A/B; // division entière de 3 par 2 : retourne 1.0
C = (float)A/B; // division décimale de 3.0 par 2 : retourne 1.5
On peut faire exactement la même chose avec les pointeurs. Si on déclare :
int16_t i = 0xF3D2;
alors l’expression &i est la valeur d’un pointeur de type int*. On peut forcer la conversion de ce pointeur vers un type char* en écrivant (char*)&i. Ce nouveau pointeur désigne l’emplacement en mémoire de la variable i, qui se comporte comme un tableau de 2 char. Donc ((char*)&i)[0] est la valeur du premier octet de l’entier i, soit 0xD2 et ((char*)&i)[1] est la valeur du second octet de l’entier i, soit 0xF3.
Autre exemple de code :
int32_t a = 0xF4E3D2C1;
Serial.println( ((char*)&a)[0] ,HEX ); // Retourne C1
Serial.println( ((char*)&a)[1] ,HEX ); // Retourne D2
Serial.println( ((char*)&a)[2] ,HEX ); // Retourne E3
Serial.println( ((char*)&a)[3] ,HEX ); // Retourne F4
C’est exactement ce que nous cherchions à obtenir ! A savoir la décomposition d’une valeur numérique en ses octets constitutifs. Cela fonctionne quel que soit le type de la variable, 16, 32 ou 64 bits, entière ou décimale. Dernier exemple :
double dbl = 123.45678912345678;
Serial.println (dbl, 14); // Retourne 123.45678912345678
for (uint8_t i=0; i<8; i++) {
Serial.print (((uint8_t*)&dbl)[i]);
Serial.print(" - ");
} // Retourne 150 - 154 - 114 - 8 - 60 - 221 - 94 - 64 -
Dans ce dernier exemple, nous avons décomposé une variable de type virgule flottante en double précision, sur 64 bits, en ses 8 octets constitutifs, que nous avons transmis par la voie série au terminal de l’IDE Arduino. Nous allons maintenant voir comment réaliser la manœuvre inverse : reconstituer cette variable à partir de ses 8 octets. Le code est le suivant :
uint8_t liste[8] = {150, 154, 114, 8, 60, 221, 94, 64};
double varDouble;
varDouble = *(double*)liste;
Serial.print (varDouble,14); // Retourne 123.45678912345678
L’explication de ce code est très simple. On a rempli un tableau de 8 octets avec les valeurs reçues par la voie série à l’étape précédentes. On déclare une variable de type double, et on lui affecte la valeur codée par les 8 octets du tableau. Pour faire cela, on rappelle que liste est certes le nom du tableau, mais c’est aussi un pointeur de type uint8_t, pointant sur l’adresse du premier élément du tableau. En écrivant (double*)liste, on force la conversion du type de ce pointeur en double. Et en faisant précéder ce nouveau pointeur converti d’une astérisque, on obtient la valeur stockée à l’adresse pointée par ce pointeur. On remarque que les 8 octets sont très simples à transmettre par un CAN bus, puisque c’est la longueur d’un message CAN standard.
Cette méthode répond donc en tous points au cahier des charges fixé dans l’introduction : code compact et efficace, utilisable quel que soit le type de variable numérique, quel que soit le type de transmission série (UART ou CAN bus) et sans aucune perte de précision des données transmises. C’est bien sûr la technique utilisée dans nos sketches.