TP — Séance 2 — Représentation numérique

Octets et bits, conversions, débordements, masques, IEEE 754, lecture portable

TP de la séance 2 de Programmation C avancée : bases, entiers, complément à deux, bits, flottants IEEE 754.
Auteur

Dr. El Hadji Bassirou TOURÉ · DMI/FST/UCAD

NoteObjectif et organisation

Écrire, compiler et tester un petit module repr.c qui manipule la représentation des nombres : afficher les octets et les bits d’une variable et les comparer à ce que montre gdb, convertir sans perdre de valeur, détecter un débordement avant le calcul et observer ce qu’en fait l’optimiseur, ranger des champs dans un mot de 16 bits, décomposer un float, lire un en-tête de fichier binaire indépendamment de l’endianness, puis faire taire -Wconversion. Durée : 2 h à 2 h 30, en sept étapes. Le dossier TP_S02_code_depart/ contient repr.h (les déclarations, à ne pas modifier sauf à l’étape 2), repr.c (les fonctions à compléter, repérées par TODO 1 à TODO 14), test_repr.c (les tests), six programmes d’observation etape*.c, l’en-tête d’image entete.bmp et un Makefile. Au départ, tout compile, mais make test signale 32 échecs ; à la fin, chaque étape doit afficher « ok ».

ImportantCommandes

Décompresser TP_S02_code_depart.zip, se placer dans le dossier TP_S02_code_depart/ qu’il crée, puis lancer le conteneur depuis ce dossier :

docker run -it --rm --security-opt seccomp=unconfined \
    -v "$(pwd)":/workspace elbachirtoure/c-advanced-course:v2.0     # lancer le conteneur

Windows, PowerShell : -v "${PWD}:/workspace" à la place de -v "$(pwd)":/workspace ; Git Bash et autres cas : TP-1, étape 2. Dans le conteneur, ce dossier est /workspace :

make                  # gcc -std=c17 -Wall -Wextra -O0 -g, tous les programmes
make test             # les tests, une ligne par etape
make ubsan            # tests et etape 3 avec -fsanitize=undefined
make asan             # tests avec AddressSanitizer
make valgrind         # chaque programme sous valgrind
make optim            # etape 3 bis, en -O0 puis en -O2
make clean            # retirer les executables
setarch -R ./etape1_octets          # adresses deterministes (sans alea)

Étape 1 : octets et bits (TODO 1)

show_bytes est donnée : elle affiche les octets d’un objet dans l’ordre des adresses. bits_texte(v, w, buf) doit écrire dans buf les w bits de poids faible de v, du bit w - 1 au bit 0, avec une espace tous les 4 bits, puis '\0' : "1011 0011" pour v = 0xb3 et w = 8.

/* etape1_octets.c -- les octets et les bits de quelques variables */
#include <stdio.h>
#include "repr.h"

int main(void)
{
    char buf[64];
    int effectif = 3913;
    int ecart = -3913;
    unsigned short port = 8080;
    double note = 15.75;
    printf("effectif :");
    show_bytes(&effectif, sizeof effectif);
    printf("ecart    :");
    show_bytes(&ecart, sizeof ecart);
    printf("port     :");
    show_bytes(&port, sizeof port);
    printf("note     :");
    show_bytes(&note, sizeof note);
    bits_texte((uint32_t)effectif, 32, buf);
    printf("effectif : %s\n", buf);
    bits_texte((uint32_t)ecart, 32, buf);
    printf("ecart    : %s\n", buf);
    return 0;
}
  1. Observer. Avant d’écrire bits_texte, lancer ./etape1_octets. Dans quel ordre les octets de effectif apparaissent-ils ? Qu’en déduire sur la machine ?
  2. Expliquer. Retrouver à la main les octets de ecart à partir de ceux de effectif, puis ceux de port.
  3. Modifier. Écrire bits_texte, puis relancer make test (étape 1) et ./etape1_octets. Vérifier sur les bits de ecart la règle \(-x = \mathord{\sim}x + 1\).
  4. Expliquer. Les 8 octets de note forment un double : écrire le mot de 64 bits, isoler le signe, l’exposant (11 bits, biais 1023) et le début de la fraction, et retrouver 15,75.

Étape 1 bis : les mêmes octets sous gdb

gdb lit la mémoire du programme arrêté : x/4xb &v affiche 4 octets (b) en hexadécimal (x) à partir de l’adresse de v ; p/x v et p/t v affichent la valeur de v en hexadécimal et en binaire.

setarch -R gdb -q ./etape1_octets
(gdb) break 24
(gdb) run
(gdb) x/4xb &effectif
(gdb) p/x effectif
(gdb) x/4xb &ecart
(gdb) p/t ecart
(gdb) p/x port
(gdb) x/8xb &note
  1. Observer. Comparer x/4xb &effectif et p/x effectif : pourquoi l’ordre des chiffres diffère-t-il ? Lequel des deux correspond à show_bytes ?
  2. Expliquer. Que donne p/t ecart, et à quelle sortie de bits_texte correspond-il ? Pourquoi p/x effectif n’affiche-t-il pas de zéros de tête, alors que x/4xb montre les quatre octets ?
  3. Observer. Les adresses de effectif, ecart et note : de combien d’octets se suivent-elles ? (La Séance 3 explique cette disposition.)

Étape 2 : conversions (TODO 2 et 3, erreur volontaire)

  1. Modifier. etend8(octet) doit rendre la valeur de l’octet lu en complément à deux (\(-128\) à 127) sans convertir vers un type signé de 8 bits : n’utiliser que des opérations sur des entiers non signés ou sur des int. Rappel : un octet \(b\) dont le bit 7 vaut 1 représente \(b - 256\). tronque8(v) rend les 8 bits de poids faible de v.

  2. Observer. La fonction compte_sous(t, n, seuil) du départ compte les éléments de t plus petits que seuil, et contient une erreur volontaire. La compilation affiche :

    Terminal :

    $ make -s 2>&1
    repr.c: In function 'compte_sous':
    repr.c:42:18: warning: comparison of integer expressions of different signedness: 'int' and 'unsigned int' [-Wsign-compare]
       42 |         if (t[i] < seuil)
          |                  ^

    Le test appelle compte_sous avec {-40, 15, -2, 320, 7} et seuil = 10. Combien d’éléments devraient être comptés ? Combien le départ en compte-t-il, et pourquoi (type de la comparaison, valeur de -40 convertie) ?

  3. Modifier. Corriger compte_sous (et sa déclaration dans repr.h) sans conversion explicite, puis vérifier que l’avertissement disparaît et que l’étape 2 passe.

Étape 3 : débordements (TODO 4 et 5)

/* etape3_montant.c -- montant d'une commande : prix unitaire (FCFA) multiplie par une quantite */
#include <stdio.h>
#include "repr.h"

int main(void)
{
    int prix = 85000;
    int quantite = 30000;
    int montant = prix * quantite;
    printf("montant = %d FCFA\n", montant);
    return 0;
}
  1. Observer. Lancer ./etape3_montant, puis make ubsan. Quel montant s’affiche ? Que signale UBSan (l’option -fsanitize=undefined), à quelle ligne ? Retrouver la valeur affichée à partir du montant exact et de \(2^{32}\), et dire pourquoi elle n’est pas garantie.

  2. Modifier. Écrire somme_sure(a, b, &r) : rendre 0 si a + b ne tient pas dans un int32_t, sinon ranger la somme dans *r et rendre 1. Le test doit être fait avant l’addition, et aucune opération ne doit déborder (utiliser INT32_MAX et INT32_MIN). Avant d’écrire le code, remplir à la main le tableau suivant, tiré des tests :

    a b la somme tient ? *r
    2147483000 648
    -2147483000 -649
    2147483000 646
    -5 9
  3. Modifier. Écrire produit_sur(a, b, &r), sur le même modèle, en calculant le produit dans un int64_t. Expliquer pourquoi ce produit ne déborde jamais.

  4. Modifier. Réécrire etape3_montant.c avec produit_sur : afficher le montant s’il tient dans un int, un message sinon, puis le montant exact calculé en long long. make ubsan ne doit plus rien signaler.

Étape 3 bis : l’optimisation change le résultat

/* etape3b_optim.c -- le meme source, compile en -O0 puis en -O2 */
#include <stdio.h>
#include <stdint.h>
#include <limits.h>

int deborde(int x)
{
    int y = x + 1;
    return y < x;
}

int main(void)
{
    printf("deborde(INT_MAX) = %d\n", deborde(INT_MAX));
    int tours = 0;
    for (int8_t i = 0; i < 200 && tours < 300; i++)
        tours++;
    printf("tours = %d\n", tours);
    return 0;
}
make optim
objdump -d --no-show-raw-insn etape3b_O0 | sed -n '/<deborde>:/,/^$/p'
objdump -d --no-show-raw-insn etape3b_O2 | sed -n '/<deborde>:/,/^$/p'
  1. Observer. Que rend deborde(INT_MAX) en -O0, puis en -O2 ? Dans le code assembleur, retrouver l’addition et la comparaison en -O0 ; que reste-t-il de deborde en -O2 ?
  2. Expliquer. Pourquoi le compilateur a-t-il le droit de rendre toujours 0 ? Écrire une version de deborde qui ne dépend pas d’un comportement indéfini, et vérifier qu’elle rend 1 aux deux niveaux.
  3. Expliquer. La boucle sur int8_t s’arrête-t-elle sans le compteur de sécurité tours ? Que signale gcc, et pourquoi la condition i < 200 est-elle toujours vraie ? Ce comportement est-il indéfini ?

Étape 4 : couleurs sur 16 bits (TODO 6 et 7)

Un écran 16 bits code une couleur en RGB565 : le rouge sur les bits 15 à 11, le vert sur les bits 10 à 5, le bleu sur les bits 4 à 0. Une composante de 8 bits perd ses bits de poids faible : le rouge garde ses 5 bits de poids fort, le vert 6, le bleu 5. etape4_drapeau.c code les trois couleurs du drapeau du Sénégal (vert (0x00, 0x85, 0x3f), jaune (0xfd, 0xef, 0x42), rouge (0xe3, 0x1b, 0x23)) et un ocre.

  1. Expliquer. Calculer à la main le code RGB565 de l’ocre (0xcc, 0x77, 0x22), en hexadécimal.
  2. Modifier. Écrire rgb565, puis rouge565, vert565 et bleu565, qui extraient chaque champ d’un code. Lancer ./etape4_drapeau et vérifier le calcul de la question précédente.
  3. Expliquer. Pourquoi rouge565(rgb565(r, g, b)) ne rend-il pas r ? Que rend-il ?

Étape 5 : IEEE 754 (TODO 8 à 11)

  1. Modifier. decompose(f, &s, &e, &m) range le signe, l’exposant biaisé et la fraction de f ; les bits sont déjà copiés dans u par memcpy. compose(s, e, m) fait l’inverse. categorie(f) rend "zero", "denormalise", "normalise", "infini" ou "nan", d’après le tableau du cours.

  2. Expliquer. Vérifier à la main la décomposition de \(-2{,}75\) attendue par le test : \(s = 1\), \(e = 128\), \(m =\) 0x300000 ; puis la valeur de compose(0, 133, 0x480000). Compléter ensuite le tableau des valeurs que etape5_flottants va afficher, avant de le lancer :

    valeur \(s\) \(e\) \(m\) catégorie
    -2.75f
    100.0f
    FLT_MAX
    INFINITY
    NAN
  3. Observer. Lancer ./etape5_flottants. Pourquoi 1e-40f n’a-t-il pas l’exposant \(-133\) que donnerait \(\log_2(10^{-40})\) ? Que vaut sa fraction ?

  4. Modifier. proches(a, b, tol) rend 1 si \(|a - b| \le \mathit{tol} \times \max(|a|, |b|)\). Pourquoi une tolérance relative ? Que montre la dernière ligne de etape5_flottants ?

Étape 6 : lire un en-tête BMP (TODO 12 à 14)

Un fichier BMP commence par un en-tête de 54 octets dont les entiers sont rangés en little-endian, quelle que soit la machine qui lit le fichier : signature "BM" à l’octet 0, taille du fichier (32 bits) à l’octet 2, position des pixels à l’octet 10, largeur et hauteur (32 bits) aux octets 18 et 22, nombre de bits par pixel (16 bits) à l’octet 28. entete.bmp contient l’en-tête d’une image de 30 sur 20 pixels.

od -A d -t x1 entete.bmp
  1. Expliquer. Dans le dump de od, retrouver à la main la taille du fichier, la largeur et la hauteur.
  2. Modifier. Écrire lire16le(p) et lire32le(p), qui assemblent les octets p[0], p[1], … par décalages et |, l’octet p[0] étant celui de poids faible ; puis ecrire32le(p, v), qui range v dans p[0] à p[3] dans le même ordre. Aucune de ces fonctions n’utilise memcpy ni de cast de pointeur.
  3. Observer. Lancer ./etape6_bmp. Pourquoi memcpy donne-t-il ici la même largeur que lire32le ? Que donnerait-il sur une machine big-endian, et lire32le ?
  4. Expliquer. Que valent les octets 18 à 21 après ecrire32le(e + 18, 60) ?

Étape 7 : -Wconversion

make clean && make CFLAGS+=-Wconversion
  1. Observer. Quels avertissements apparaissent, dans quelles fonctions ? Celui de moyenne8 signale-t-il un vrai risque de perte ?
  2. Modifier. Corriger repr.c pour que la compilation avec -Wconversion n’affiche plus rien, en rendant explicites les conversions sûres (moyenne8, et celles que votre propre code a introduites), sans changer les résultats.
  3. Vérifier. make test : « tous les tests passent ». Puis make ubsan, make asan et make valgrind : aucun rapport ne doit apparaître.

Pour cette séance

Le cours

Les slides

À lire en ligne

À télécharger

Retour au sommet