dimanche 21 octobre 2018

Opérations bit à bit (Arduino)

Si la lecture d'une expression du genre "byte var2 = var1 & ~(1 << 3); " vous laisse dubitatif, ce billet est pour vous: nous allons tenter de démystifier les opérations bit à bit (bitwise operations, en anglais).

Des bits?

Toutes les variables que vous utilisez dans un programme sont constituées d'un certain nombre de bits, et chacun de ces bits peut prendre deux valeurs possibles: 0 ou 1. La notation binaire permet de montrer de façon explicite la valeur de chacun de ces 8 bits.

Par exemple, dans la ligne ci-dessous, on créé une variable de type byte nommée "mon_octet" dont tous les bits on la valeur 1:

byte mon_octet = 0b11111111;

(Notez que "0b" au début de l'expression sert à indiquer qu'il s'agit d'un nombre en binaire. On aurait obtenu le même résultat en utilisant "255" (notation décimale) ou "0xFF" (notation hexadécimale).

Une variable de type int comporte 16 bits! Donc, quand vous écrivez en notation décimale quelque chose comme...

int mon_entier = 4807;

...la valeur des 16 bits sera réglée de la même façon que si vous aviez écrit:

int mon_entier = 0b0001001011000111;

La particularité des opérations bit à bit (ou bitwise), c'est qu'elles s'appliquent à chaque bit individuellement.

À quoi ça sert?

Pourquoi quelqu'un aurait-il l'idée saugrenue de considérer individuellement chaque bit d'une variable? Essentiellement, parce qu'il s'agit de la façon la plus compacte d'emmagasiner et de transmettre de l'information dans des circuits numériques.

Par exemple, le microcontrôleur Atmega 328 qui constitue le cerveau de la carte Arduino Uno comporte des registres qui permettent de lire ou de modifier l'état logique de ses broches.  Le registre DDRD, par exemple, comporte 8 bits associés aux broches 0 à 7 de l'Arduino.  Si DDRD a la valeur 0b11110000, ça signifie que les broches 0 à 3 sont réglées en entrée, alors que les broches 4 à 7 sont réglées en sorties.  Lorsque vous utilisez la fonction pinMode() du langage Arduino, vous modifiez indirectement la valeur de ce registre, mais il est aussi possible de modifier directement le registre au moyen des opérations bit à bit.

De nombreux capteurs numériques qui communiquent avec l'Arduino comportent aussi des registres à 8 bits qu'il faut lire et/ou modifier lors de leur utilisation. Les opérations bit à bit nécessaires sont souvent effectuées en arrière-plan par des bibliothèques.

Vous pourriez vous mêmes choisir d'utiliser des opérations bit à bit à l'intérieur des scripts que vous écrivez, afin d'économiser la mémoire.  En effet, l'information équivalent à 8 variables booléennes peut être emmagasinée dans une seule variable de type "byte" (1 octet), alors que si vous utilisez 8 variables distinctes de type "bool" (qui ont une taille d'un octet chacun), vous utiliserez 8 fois plus de mémoire pour emmagasiner la même quantité d'information.  (C'est encore pire si vous emmagasinez l'information dans 8 variables distinctes de type "int": vous accaparez alors 16 octet, soit 16 fois plus que nécessaire!)

Voyons maintenant chaque opérateur bit à bit.

L'opérateur NON (NOT), symbolisé par ~, a pour effet d'inverser chaque bit: les bits qui valaient 1 deviennent 0, et ceux qui valaient 0 deviennent 1.

Dans l'exemple illustré ci-dessous, le bit numéro 7 de l'octet A vaut 1, donc le bit numéro 7 de l'octet ~A vaut 0. Le bit numéro 6 de l'octet A vaut 0, donc le bit numéro 6 de l'octet ~A vaut 1.  Et ainsi de suite.


L'opérateur ET (AND),dont le symbole est &, donne 1 si les deux bits valent 1. Sinon, il donne zéro.

Dans l'exemple illustré ci-dessous, le bit numéro 7 de l'octet A vaut 1 et le bit numéro 7 de l'octet B vaut zéro, donc le bit numéro 7 de l'expression "A & B" vaut 0.

Par contre, le bit numéro 5 de "A & B" vaut 1, puisque le bit numéro 5 de A et le bit numéro 5 de B valent 1 tous les deux.


L'opérateur OU (OR), dont le symbole est |, donne 0 uniquement si les deux bits valent 0.  Sinon, l'opération donne 1.


L'opérateur OU Exclusif (XOR), dont le symbole est ^, donne 1 à la condition que les deux bits soient de valeurs différentes. Si les deux bits ont la même valeur, l'opération donne 0.



L'opérateur décalage vers la gauche (left shift), dont le symbole est <<, déplace tous les bits vers la gauche, du nombre de positions spécifié.

Dans l'exemple ci-dessous, l'opération A << 1 déplace tous les bits d'une position vers la gauche.  Ainsi, le bit numéro 7 de A << 1 a la même valeur que le bit numéro 6 de A, le bit numéro 6 de A << 1 a la même valeur que le bit numéro 5 de A, etc.

L'exemple montre également l'opération A << 3, qui décale tous les bits de 3 positions vers la gauche. Le bit numéro 7 de A << 3 a donc la même valeur que le bit numéro 4 de A, le bit numéro 6 de A << 3 a la même valeur que le bit numéro 3 de A, etc.


L'opérateur décalage vers la droite (right shift), dont le symbole est >>, déplace tous les bits vers la droite, du nombre de positions spécifié.

Comme on peut le constater dans l'exemple ci-dessous, tous les bits de A >> 1 sont décalés d'une position vers la droite par rapport à ceux de A. Le bit numéro 6 de A >> 1 a donc la valeur du bit numéro 7 de A, le bit numéro 5 de A >> 1 a la valeur du bit numéro 6 de A, etc.

Quant aux bits de A >> 3, ils ont été décalés vers la droite de trois positions par rapport aux bits de l'octet A.


Quelques exemples concrets:

Nous allons maintenant mentionner quelques applications fréquentes qui nécessitent une combinaison de plusieurs opérateurs bit à bit: la lecture d'un bit, l'assignation de la valeur 1 à un bit, l'assignation de la valeur 0 à un bit et l'inversion de la valeur d'un bit.

Toutes ces opérations impliquent l'usage d'un masque, c'est à dire un octet dont la valeur permet de laisser intacte la valeur des bits qu'on ne désire pas modifier.

Lecture d'un bit en particulier

Pour lire la valeur d'un bit en particulier, on utilise l'opérateur & avec un masque dont tous les bits sont nuls, sauf celui qui occupe la position pour laquelle on souhaite connaître la valeur.

L'exemple ci-dessous montre l'opération A & (1 << 2), qui permet de connaître la valeur du bit numéro 2 de A.

1 << 2 est le masque.  Il s'agit du nombre 1 (donc 00000001) qui a subit un décalage de deux positions vers la gauche (ce qui donne 00000100). 

Tous les bits de A & (1 << 2) seront donc nuls, sauf le bit numéro 2, qui vaudra 1 à la condition que le bit numéro 2 de A soit 1 lui aussi:


Par contre, si la valeur du bit testé est nulle, tous les bits de l'opération seront nuls.

Par exemple, dans l'exemple ci-dessous, nous cherchons la valeur du bit numéro 4 de A grâce à l'opération A & (1 << 4).

Notre masque est maintenant 1 << 4, qui correspond à 00000001 ayant subit un décalage vers la gauche de 4 positions (donc 00010000). Ce masque garantit que seule la valeur du bit numéro 4 de A aura une influence sur le résultat. Mais ce bit est nul, et tous les bits de l'opération A & (1 << 4) sont donc nuls.


Bref: si le bit n de A vaut zéro, l'opération A & (1 << n) retourne la valeur zéro, alors que si le bit n de A vaut 1, l'opération A & (1 << n) retourne autre chose que zéro.  Il ne reste plus qu'à tester si le résultat est nul ou non pour en déduire la valeur du bit n.

Assignation de la valeur 1 à un bit en particulier

Pour que le bit de position n de l'octet A prenne la valeur 1 et que tous les autres bits conservent leur valeur initiale, on utilise l'opération A | (1 << n).

Dans l'exemple ci-dessous, on désire que le bit numéro 4 de A devienne 1. On utilise donc le masque 1 << 4, qui vaut 00010000, combiné avec l'opérateur "ou".


En général, on voudra que ce résultat remplace la valeur initiale de A. On pourra donc écrire:

A = A | (1 << 4);

...ou encore une forme plus compacte:

A |= 1 << 4;

Assignation de la valeur 0 à un bit en particulier

Pour que le bit de position n de l'octet A prenne la valeur 0 et que tous les autres bits conservent leur valeur initiale, on utilise l'opération A &  ~(1 << n).

Cette fois, nous utilisons l'opération NON (~) afin d'obtenir un masque dont tous les bits valent 1, sauf celui qui occupe la même position que le bit que nous désirons modifier.  Dans l'exemple ci-dessous, nous assignons la valeur 0 au bit numéro 5 seulement. 1 << 5 aurait donné 00100000, mais ~(1 << 5) donne plutôt 11011111.  On peut voir que le résultat de l'opération A & ~(1 << 5) donne la même chose que A, sauf que le bit numéro 5 est devenu nul.


Ici encore, si vous désirez que le résultat remplace l'ancienne valeur de A, il faudra ajouter une assignation:

A = A & ~ ( 1 << 5);

... ou encore:

A &=  ~ ( 1 << 5);

Inversion d'un bit en particulier

Voyons maintenant comment inverser ("toggle") la valeur d'un seul bit tout en laissant les autres bits inchangés.  Cette fois, nous utilisons le "ou exclusif".

Pour que le bit de position n de l'octet A soit inversée et que tous les autres bits conservent leur valeur initiale, on utilise l'opération A ^ (1 << n).

Voyez l'exemple ci-dessous: le résultat de l'opération A ^ (1 << 7) est identique à A, sauf que le bit numéro 7, qui était 1, est maintenant 0.

Dans l'exemple ci-dessous, nous inversons le bit numéro 6, qui passe de 0 à 1:


Pour que la variable initiale soit modifiée:

A = A ^ (1 << 6);

... ou la version plus compacte:

A ^= (1 << 6);

Terminus, tout le monde descend!

Je n'irai pas plus loin que ces quelques exemples qui, j'espère, vous auront aidé à comprendre le principe des opérations bit à bit. Il existe bien entendu d'autres applications possibles, comme par exemple assigner simultanément une valeur à plusieurs bits différents tout en n'affectant pas la valeur de plusieurs autres bits. Il y a aussi la possibilité d'effectuer certaines opérations mathématiques avec plus de rapidité (le décalage d'une position vers la gauche correspond à une multiplication par deux, alors que le décalage d'une position vers la droite correspond à une division par deux).

Un sketch de démonstration

Pour finir, je vous propose un sketch Arduino qui illustre toutes les opérations présentées dans cet article.  Au départ, deux octets sont générés au hasard et le résultat des différentes opérations s'affiche dans le moniteur série.  La totalité du programme se trouve à l'intérieur de setup, vous devez donc appuyer sur le bouton reset de votre carte Arduino pour générer une nouvelle paire d'octets.



Yves Pelletier   (TwitterFacebook)

2 commentaires: