CSC4103 – Programmation système

François Trahay

Gaël Thomas

2024

Le langage C

Présentation du module

Objectifs du module:

Modalités:


Contenu du module

Partie Programmation

Partie Système

Evaluation


Déroulement d’une séance

Système de classe inversée. Pour chaque séance :

Attention ! Cela ne fonctionne que si vous travaillez sérieusement avant la séance.

Hypothèse: les étudiants suivant ce cours sont des adultes responsables.

Les supports de cours à étudier pour chaque séance sont disponibles dans plusieurs formats:


Ressources disponibles

Pour vous aider, vous avez à votre disposition:


Conseils pour la suite de votre scolarité

Utilisation de ChatGPT (ou autre IA)

Les IA génératives sont de super outils qui peuvent améliorer la productivité des développeurs. Il existe des plugins qui s’intègrent directement dans certains IDE et qui permettent de générer du code à la place du développeur. Si ces outils peuvent être utiles pour un développeur dans une entreprise, ils sont à proscrire dans le cadre de vos études.

Le but des modules que vous suivez est de vous apprendre à programmer et à comprendre certains concepts ou problèmes. Si une IA écrit le code à votre place, vous ne pouvez pas être confrontés à ces problèmes/concepts et vous n’êtes pas capable de vérifier que le code généré est correct.

Par ailleurs, lorsqu’on vous évalue (que ce soit lors d’un DM, ou TP noté), vos enseignants évaluent votre travail, pas celui d’une IA. Si vous rendez du code généré par une IA, il est possible que d’autres étudiants fassent de même, et la probabilité pour que vous rendiez le même code (ou du code similaire) est forte. Ceci s’apparente à la triche, ce qui est fortement sanctionné à Télécom SudParis (0/20 au module, impossibilité de passer un éventuel CF2, potentielles sanctions supplémentaires décidées par la DF)

Il convient donc de désactiver ces éventuels plugins dans vos IDE, que ce soit pendant les TP, ou les DM. Vous êtes bien sûr libres de les réactiver pour votre projets perso.

Utilisation des corrigés

Les corrigés des différents TP sont disponibles en ligne et dans certains modules, des enregistrements vidéo des TP viennent compléter les ressources que nous proposons aux étudiants pour les aider. Il arrive que des étudiants se contentent de copier-coller les corriger, puis compiler et tester, avant de passer à l’exercice suivant. Ces étudiants ne sont donc pas confrontés aux problèmes qu’on souhaite montrer et n’apprennent pas à les résoudre par eux-même.

Il convient donc de faire les TP par vous même. Si vous êtes bloqués, posez des questions à l’enseignant qui vous mettra sur la voie. En faisant cela, vous progresserez et serez capable de faire les TP sans avoir le corrigé (ce qui sera utile lors du TP noté final, mais également dans votre vie professionnelle future).

Travail en groupe

L’entraide est bien sûr encouragée dans nos enseignements. Lorsque vous ne comprenez pas un concept ou lorsque vous révisez, nhésitez pas solliciter vos camarades. Par contre, lorsqu’il s’agit d’un travail évalué (DM, TP noté, etc.), cette entraide est contre-productive. Notre but est d’évaluer vos compétences et vous faire progresser. Récupérer le code d’un camarade, ou partager votre code avec eux (que ce soit en s’envoyant des fichiers, via un répo git, ou via une messagerie type discord) est très risqué. En cas de triche, tous les tricheurs (qu’ils aient récupéré le code d’un camarde, ou partagé le leur) auront 0/20 au module.

D’une manière générale, vous avez tous un bon niveau en informatique et vous devriez tous pouvoir valider ce module si vous y travaillez. Nous sommes conscients du fait que votre emploi du temps est parfois très chargé. Mais vous pouvez le faire par vous même sans tricher.


C vs. Java

Mon premier programme en C

Fichier *.c

/* hello_world.c */
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char** argv) {
  printf("Hello World!\n");
  return EXIT_SUCCESS;
}

Compilation/execution:

$ gcc hello_world.c -o hello_world -Wall -Werror
$ ./hello_world
Hello World!


Déclaration de variable

Pour les types simples, déclaration identique à Java:

int var1;
int var2, var3, var4;
int var5 = 42;

Types disponibles:

Pour les entiers: possibilité de préfixer le type par unsigned. Les variables sont alors non-signées (ie. positives).

La taille d’une variable entière (ie. le nombre de bits/octets) dépend de l’implémentation. Le standard C ne spécifie que la taille minimum. Ainsi, un int doit faire au moins 16 bits, alors que la plupart des implémentations modernes utilisent 32 bits pour les int. Il convient donc de ne pas se reposer sur ces types lorsqu’on a besoin d’un nombre précis de bits/octets.

Pour cela, il est préférable d’utiliser les types fournis par stdint.h: uint8_t (8 bits), uint16_t (16 bits), uint32_t (32 bits), ou uint64_t (64 bits).

Comme en Java, les variables déclarées dans une fonction sont locales à la fonction (elles disparaissent donc dès la sortie de la fonction). Les variables déclarées en dehors d’une fonction sont globales: elles sont accessibles depuis n’importe quelle fonction.


Opérateurs et Expressions

La liste des opérateurs disponibles est à peu près la même qu’en Java:

Mais également:


Opérateurs bit à bit

Possibilité de travailler sur des champs de bits.

/* bits.h */

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>

int main(int argc, char** argv) {
  uint32_t v = 1;
  int i;

  /* l'operateur << decale vers la gauche */
  for(i=0; i<32; i++) {
    /* v << i  decale les bits de v de i places vers la gauche
     * c'est equivalent à calculer v*(2^i)
     */
    printf("v<<%d = %u\n", i, v<<i);
  }

  v = 5;
  /* v | 3 effectue un OU logique entre les bits de v et la representation binaire de 3
   * 101 | 11 = 111 (7)
   */
  printf("%u | %u = %u\n", v, 3, v|3);

  /* v & 3 effectue un ET logique entre les bits de v et la representation binaire de 3
   * 101 & 11 = 001 (1)
   */
  printf("%u & %u = %u\n", v, 3, v&3);

  /* v ^ 3 effectue un XOR logique entre les bits de v et la representation binaire de 3
   * 101 ^ 011 = 110 (6)
   */
  printf("%u ^ %u = %u\n", v, 3, v^3);

  /* ~v effectue un NON logique des bits de v
   * ~ 00...00101 = 11..11010 (4294967290)
   */
  printf("~%u = %u\n", v, ~v);

  return EXIT_SUCCESS;
}

Remarque

Lorsqu’on opère un décalage (avec <<) sur une valeur signée (par exemple, un int), le bit de signe n’est pas modifié par le décalage. Par exemple, si les bits d’un int a sont à 1010 0000 0000 0000 0000 0000 0000 0000, le résultat de a >> 1 est 1001 0000 0000 0000 0000 0000 0000 0000.


Structures algorithmiques

Comme en Java:


Affichage / Lecture

/* formats.c */
#include <stdio.h>

int main(int argc, char** argv) {
  int v;
  printf("Entrez la valeur de v:\n");
  scanf("%d", &v);
  printf("v = %d (en decimal)\n", v);
  printf("v = %u (en decimal non signe)\n", v);
  printf("v = %x (en hexadecimal)\n", v);
  printf("v = %o (en octal)\n", v);
  printf("v = %c (en ASCII)\n", v);

  double a;
  scanf("%lf", &a);
  printf("a = %f (en flottant)\n", a);
  printf("a = %lf (en flottant double precision)\n", a);
  printf("a = %e (en notation scientifique)\n", a);

  char *chaine = "Bonjour";
  printf("chaine = %s\n", chaine);
  printf("chaine = %p (adresse)\n", chaine);

  printf("On peut aussi afficher le caractère %%\n");
}

Fonctions

Déclaration:

type_retour nom_fonc(type_param1 param1, type_param2 param2) {
/* déclaration des variables locales */
/* instructions à exécuter */
}

En C, il est d’usage de nommer les fonctions en minuscule, en séparant les mots par _. Cette convention se nomme Snake case.

Par exemple, l’équivalent en C de la fonction Java calculerLeMinimum() (qui utilise la convention de nommage camel case) sera calculer_le_minimum().


Les structures et les tableaux

Du type primitif au type composé


Les structures

Une structure est une définition d’un nouveau type de données

Remarque: les sous-types d’une structure s’appellent des champs

Définition d’une nouvelle structure avec :

struct nom_de_la_structure {
  type1 nom_du_champs1;
  type2 nom_du_champs2;
  ...
};

Par exemple:

struct nombre_complexe {
  int partie_reelle;
  int partie_imaginaire;
};

Par convention, les noms de structures commencent par une minuscule en C

Une structure peut aussi être composée à partir d’une autre structure, comme dans cet exemple:

struct point {
  int x;
  int y;
};

struct segment {
  struct point p1;
  struct point p2;
};

En revanche, une structure ne peut pas être composée à partir d’elle-même. À titre d’illustration, l’exemple suivant n’est pas correct:

struct personnage {
  struct personnage ami;
  int               point_de_vie;
};

Cette construction est impossible car il faudrait connaître la taille de la structure personnage pour trouver la taille de la structure personnage.


Déclaration d’une variable de type structure

struct nombre_complexe z1, z2, z3;
/* partie_relle de z prend la valeur 0 */
/* partie_imaginaire de z prend la valeur 1 */
struct nombre_complexe i = { 0, 1 };
/* autre solution : */
struct nombre_complexe j = { .partie_reelle=0, .partie_imaginaire=1 }; 

L’initialisation d’une variable de type structure est différente lorsque la variable est déclarée globalement ou localement. On vous rappelle qu’une variable globale si elle est déclarée en dehors de toute fonction. Sinon, on dit qu’elle est locale.

Lorsqu’une variable de type structure est déclarée en tant que variable globale sans être initialisée, le compilateur initialise chacun de ces champs à la valeur 0. En revanche, lorsqu’une structure est déclarée en tant que variable locale dans une fonction sans être initialisée, ces champs prennent une valeur aléatoire.

Par exemple, dans :

struct nombre_complexe i;

void f() {
  struct nombre_complexe j;
}

Les champs de i sont initialisés à 0 alors que ceux de j prennent une valeur aléatoire.

On peut aussi partiellement initialiser une structure comme dans l’exemple suivant :

struct nombre_complexe j = { 1 }; 

Dans ce cas, le champs partie_relle prend la valeur 1 et le champs partie_imaginaire prend soit la valeur 0 si la variable est globale, soit une valeur aléatoire si la variable est locale à une fonction.


Accès aux champs d’une variable de type structure

struct point {
  int x;
  int y;
};

struct ligne {
  struct point p1;
  struct point p2;
};
void f() {
  struct point p;
  struct ligne l;

  p.x = 42;
  p.y = 17;
  l.p1.x = 1;
  l.p1.y = 2;
  l.p2 = p; /* copie p.x/p.y dans l.p2.x/l.p2.y */

  printf("[%d %d]\n", p.x, p.y);
}

Les tableaux

    int          a[5];      /* tableau de 5 entiers */
    double       b[12];     /* tableau de 12 nombres flottants */
    struct point c[10];     /* tableau de 10 structures points */
    int          d[12][10]; /* tableau de 10 tableaux de 12 entiers */
                            /* => d est une matrice 12x10 */

Accès aux éléments d’un tableau

void f() {
  int x[3];
  int y[3];
  int i;

  /* 0 est le premier élément, 2 est le dernier */
  for(i=0; i<3; i++) {
    x[i] = i;
    y[i] = x[i] * 2;
  }
}

Tableaux et structures

    struct point {
      int x;
      int y;
    };

    struct triangle {
      struct point sommets[3];
    };
void f() {
  struct triangle t;

  for(i=0; i<3; i++) {
    t.sommets[i].x = i;
    t.sommets[i].y = i * 2;
  }
}

Différences par rapport à Java

void f() {
  int x[3];

  x[4] = 42; /* Erreur silencieuse !!! */
             /* Écriture à un emplacement aléatoire en mémoire */
             /* le bug pourra apparaître n'importe quand */ 
}

Initialisation d’un tableau lors de sa déclaration

type_element nom_variable[taille] = { e0, e1, e2, ... };

En l’absence d’initialisation : * Si le tableau est une variable globale, chaque élément est initialisé à 0 * Sinon, chaque élément est initialisé à une valeur aléatoire

Lorsqu’on initialise un tableau lors de sa déclaration, on peut omettre la taille du tableau. Dans ce cas, la taille du tableau est donnée par la taille de la liste d’initialisation.

int x[] = { 1, 2, 3 };  /* tableau à trois éléments */
int y[6] = { 1, 2, 3 }; /* tableau à six éléments, avec les trois premiers initialisés */

Initialisation mixte de tableaux et structures

    struct point {
      int x;
      int y;
    };

    struct triangle {
      struct point sommets[3];
    };

    struct triangle t = {
      { 1, 1 },
      { 2, 3 },
      { 4, 9 }
    };

Tableaux et chaînes de caractères

Une chaîne de caractère est simplement un tableau de caractères terminé par le caractère '\0' (c’est à dire le nombre zéro)

char yes[] = "yes";

est équivalent à

char yes[] = { 'y', 'e', 's', '\0' };

Passage par valeur et par référence


Passage par valeur – les types primitifs

/* le x de f et le x du main sont deux variables distinctes */
/* le fait qu'elles aient le même nom est anecdotique */
void f(int x) {
  x = 666;
  printf("f : x = %d\n", x);           /* f : x = 666 */
}

int main() {
  int x = 42;
  f(x);                         /* x est copié dans f */
      /* => le x de main n'est donc pas modifié par f */
  printf("g : x = %d\n", x);            /* g : x = 42 */
  return 0;
}

Passage par valeur – les structures

struct point {
  int x;
  int y;
};

void f(struct point p) {
  p.x = 1;
  printf("(%d, %d)\n", p.x, p.y);        /* => (1, 2) */
}

int main() {
  struct point p = { -2, 2 };
  f(p);                       /* p est copié dans f */
  printf("(%d, %d)\n", p.x, p.y);      /* => (-2, 2)  */
  return 0;
}

Passage par référence – les tableaux

void print(int x[], int n) {
  for(int i=0; i<n; i++) {
    printf("%d ", x[i]);
  }
  printf("\n");
}
int main() {
  int tab[] = { 1, 2, 3 };

  print(tab, 3);  /* => 1 2 3 */
  f(tab);  
  print(tab, 3); /* => 1 42 3 */

  return 0;
}
/* x est une référence vers le tableau original */
void f(int x[]) {
  x[1] = 42;           /* => modifie l'original */
}

Notions clés


Modularité

Objectifs de la séance


Modularité en C vs. Java

Beaucoup de concepts sont les même qu’en Java


Module en C

Deux fichiers par module. Par exemple, pour le module mem_alloc:


Exemple: le module mem_alloc

/* mem_alloc.h */
#include <stdlib.h>
#include <stdint.h>
#define DEFAULT_SIZE 16

typedef int64_t mem_page_t;
struct mem_alloc_t {
  /* [...] */
};

/* Initialize the allocator */
void mem_init();

/* Allocate size consecutive bytes */
int mem_allocate(size_t size);

/* Free an allocated buffer */
void mem_free(int addr, size_t size);
/* mem_alloc.c */
#include "mem_alloc.h"
struct mem_alloc_t m;
void mem_init() {  /* ... */  }
int mem_allocate(size_t size) {
  /* ... */
}
void mem_free(int addr, size_t size) {
  /* ... */
}
#include "mem_alloc.h"
int main(int argc, char**argv) {
  mem_init();
  /* ... */
}

Compilation de modules

Compilation en trois phases:


Préprocesseur

Le préprocesseur transforme le code source pour le compilateur

La directive #if permet, par exemple, de fournir plusieurs implémentations d’une fonction. Cela peut être utilisé pour des questions de portabilité.

#if __x86_64__
  void foo() { /* implementation pour CPU intel 64 bits */ }
#elif __arm__   /* équivalent à #else #if ... */
  void foo() { /* implementation pour CPU ARM */ }
#else
  void foo() {
    printf("Architecture non supportée\n");
    abort();
#endif

Il y a deux syntaxes pour la directive #include: * #include <fichier>: le préprocesseur cherche fichier dans un ensemble de répertoires systèmes (/usr/include par exemple) * #include "fichier"" le préprocesseur cherchefichier` dans le répertoire courant, puis dans les répertoires systèmes.

On utilise donc généralement #include "fichier" pour inclure les fichiers d’entête définis par le programme, et #include <fichier> pour les fichiers d’entête du système (stdio.h, stdlib.h, etc.)


Compilateur

Compilation : transformation des instructions C en instructions “binaires”


Editeur de liens

Edition de liens : regroupement des fichiers objets pour créer un exécutable

gcc -o executable mem_alloc.o module2.o [...] moduleN.o

Règles de compilations

Lorsque le nombre de module devient élevé, il devient difficile de savoir quel(s) module(s) recompiler. On automatise alors la chaîne de compilation, en utilisant l’outil make.

Puisque la compilation se fait en 3 phases, 3 types d’erreurs peuvent survenir: * une erreur du préprocesseur (ie. une macro est mal écrite):

$ gcc -c foo.c 
foo.c:1:8: error: no macro name given in #define directive
 #define
        ^
  $ gcc -c erreur_compil.c
erreur_compil.c: In function ‘f’:
erreur_compil.c:3:1: error: expected ‘;’ before ‘}’ token
 }
 ^
$ gcc -o plip main.o
main.o : Dans la fonction « main » :
main.c:(.text+0x15) : référence indéfinie vers « mem_init »
collect2: error: ld returned 1 exit status

Fichiers ELF

La liste complète des sections du format ELF est disponible dans la documentation (man 5 elf).

La table des symboles contient la liste des fonctions/variables globales (ou statiques) définies ou utilisées dans le fichier. L’outil nm permet de consulter cette table. Par exemple:

$ nm mem_alloc.o
0000000000000000 C m
0000000000000007 T mem_allocate
0000000000000012 T mem_free
0000000000000000 T mem_init
$ nm main.o
0000000000000000 T main
                 U mem_init

Pour chaque symbole, nm affiche l’adresse (au sein d’une section), le type (donc, la section ELF), et le nom du symbole.

Ces informations sont également disponible via la commande readelf:

$ readelf  -s mem_alloc.o 

Table de symboles « .symtab » contient 12 entrées :
   Num:    Valeur         Tail Type    Lien   Vis      Ndx Nom
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS mem_alloc.c
     2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1 
     3: 0000000000000000     0 SECTION LOCAL  DEFAULT    2 
     4: 0000000000000000     0 SECTION LOCAL  DEFAULT    3 
     5: 0000000000000000     0 SECTION LOCAL  DEFAULT    5 
     6: 0000000000000000     0 SECTION LOCAL  DEFAULT    6 
     7: 0000000000000000     0 SECTION LOCAL  DEFAULT    4 
     8: 0000000000000001     0 OBJECT  GLOBAL DEFAULT  COM m
     9: 0000000000000000     7 FUNC    GLOBAL DEFAULT    1 mem_init
    10: 0000000000000007    11 FUNC    GLOBAL DEFAULT    1 mem_allocate
    11: 0000000000000012    14 FUNC    GLOBAL DEFAULT    1 mem_free

L’utilitaire objdump permet lui aussi d’examiner la table des symboles:

$ objdump -t mem_alloc.o 

mem_alloc.o:     format de fichier elf64-x86-64

SYMBOL TABLE:
0000000000000000 l    df *ABS*  0000000000000000 mem_alloc.c
0000000000000000 l    d  .text  0000000000000000 .text
0000000000000000 l    d  .data  0000000000000000 .data
0000000000000000 l    d  .bss   0000000000000000 .bss
0000000000000000 l    d  .note.GNU-stack    0000000000000000 .note.GNU-stack
0000000000000000 l    d  .eh_frame  0000000000000000 .eh_frame
0000000000000000 l    d  .comment   0000000000000000 .comment
0000000000000000       O *COM*  0000000000000001 m
0000000000000000 g     F .text  0000000000000007 mem_init
0000000000000007 g     F .text  000000000000000b mem_allocate
0000000000000012 g     F .text  000000000000000e mem_free

Portée des variables locales

Une variable déclarée dans une fonction peut être

Puisqu’une variable locale statique est allouée au chargement du programme, elle apparaît dans la liste des symboles :

$ nm plop.o
0000000000000000 T function
0000000000000000 d variable_locale_static.1764

Ici, le symbole variable_locale_static.1764 correspond à la variable variable_locale_static déclarée static dans la fonction function. Le suffixe .1764 permet de différencier les variables nommées variable_locale_static déclarées dans des fonctions différentes.


Portée des variables globales

Une variable déclarée dans le fichier fic.c en dehors d’une fonction peut être:

Les variables globales (déclarées extern, static, ou “normales”) se retrouvent dans la table des symboles de l’objet, mais dans des sections ELF différentes :

$ nm plop.o
0000000000000000 T function
                 U var_extern
0000000000000000 D var_globale
0000000000000004 d var_static_globale

La variable var_extern (déclarée avec extern int var_extern;) est marquée "U" (undefined). Il s’agit donc d’une référence à un symbole présent dans un autre objet.

La variable var_globale (déclarée avec int var_globale = 12;) est marquée "D" (The symbol is in the initialized data section). Il s’agit donc d’une variable globale initialisée .

La variable var_static_globale (déclarée avec static int var_static_globale = 7;) est marquée “d” (The symbol is in the initialized data section). Il s’agit donc d’une variable globale “interne”. Il n’est donc pas possible d’accèder à cette variable depuis un autre objet:

$ gcc plop.o  plip.o  -o executable
plip.o : Dans la fonction « main » :
plip.c:(.text+0xa) : référence indéfinie vers « var_static_globale »
collect2: error: ld returned 1 exit status

Bibliothèque

Avantages/inconvénients des bibliothèques statiques:

- Taille de l’exécutable important (puisqu’il inclut la bibliothèque);

- En cas de nouvelle version d’une bibliothèque (qui corrige un bug par exemple), il faut recompiler toutes les applications utilisant la bibliothèque;

- Duplication du code en mémoire;

+ L’exécutable incluant une bibliothèque statique fonctionne “tout seul” pas besoin d’autres fichiers).

Avantages/inconvénients des bibliothèques dynamiques:

+ Taille de l’exécutable réduite (puisqu’il n’inclut qu’une référence à la bibliothèque);

+ En cas de nouvelle version d’une bibliothèque (qui corrige un bug par exemple), pas besoin de recompiler les applications utilisant la bibliothèque;

+ Une instance du code en mémoire est partageable par plusieurs processus;

- L’exécutable incluant une bibliothèque dynamique ne fonctionne pas “tout seul”: il faut trouver toutes les bibliothèques dynamiques nécessaires.

Les “dépendances” dues aux bibliothèques dynamiques sont visibles avec ldd:

$ ldd executable
    linux-vdso.so.1 (0x00007fff9fdf6000)
    libmem_alloc.so (0x00007fb97cb9f000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fb97c7ca000)
    /lib64/ld-linux-x86-64.so.2 (0x0000555763a9b000)

Création d’une bibliothèque

Il existe 2 types de bibliothèques

LD_LIBRARY_PATH

Pour exécuter un programme utilisant une bibliothèque dynamique, le système doit charger en mémoire le programme ainsi que la biblbiothèque. Si la bibliothèque n’est pas installée dans un répertoire standard (typiquement dans /usr/lib), il peut être nécessaire d’indiquer où trouver cette bibliothèque grâce à la variable d’environnement LD_LIBRARY_PATH.

$ ./mon_programme
mon_programme: error while loading shared libraries: libtruc.so: cannot open shared object file: No such file or directory

$ export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/home/trahay/libs/libtruc/

$ ./mon_programme

It works !

Avantages et inconvénients

L’avantage d’une bibliothèque statique est qu’il n’est nécessaire de connaître son emplacement qu’au moment d’édition de liens. Une fois l’exécutable crée, celui-ci inclue la bibliothèque statique. On peut donc déplacer l’exécutable, supprimer la bibliothèque statique, recopier l’exécutable sur une autre machine sans empêcher son exécution.

Toutefois, lorsqu’une bibliothèque statique est mise à jour (par exemple pour corriger un bug ou une faille de sécutité), il est nécessaire de recompiler tous les programmes utilisant cette bibliothèque. Pour des bibliothèques très utilisées (par exemple, la libc), cela peut être long et on risque d’oublier de recompiler certains programmes.

A l’inverse, si une bibliothèque dynamique est mise à jour, cette mise à jour est directement disponible pour toutes les applications utilisant la bibliothèque. Ceci explique pourquoi la plupart des distributions Linux reposent aujourd’hui sur des bibliothèques dynamiques plutot que statiques.

Changement d’ABI

Attention, si une bibliothèque dynamique est mise à jour et que son ABI (Application Binary Interface) change (par exemple si la signature d’une fonction change), il sera nécessaire de recompiler les applications utilisant cette biblliothèque.


Organisation

Organisation classique d’un projet:

Besoin d’utiliser des flags:

gcc -c main.c -I../memory/
gcc -o executable main.o -L../memory/ -lmem_alloc

A l’exécution:

export LD_LIBRARY_PATH=.:../memory

Par défaut, le compilateur va chercher les fichiers d’entête dans un certain nombre de répertoires. Par exemple, gcc cherche dans:

L’option -I ajoute un répertoire à la liste des répertoires à consulter. Vous pouvez donc utiliser plusieurs fois l’option -I dans une seule commande. Par exemple:

gcc -c main.o -Imemory/ -Itools/ -I../plop/

De même, l’éditeur de liens va chercher les bibliothèques dans un certain nombre de répertoires par défaut. La liste des répertoires parcourus par défaut par ld (l’éditeur de lien utilisé par gcc) est dans le fichier /etc/ld.so.conf. On y trouve généralement (entre autre):

Si la variable LD_LIBRARY_PATH est mal positionnée, vous risquez de tomber sur ce type d’erreur au lancement de l’application:

  $ ./executable
./executable: error while loading shared libraries: libmem_alloc.so: cannot open \
 shared object file: No such file or directory

Makefile

cible : dependance1 dependance2 ... dependanceN`
<TAB>commande

La commande make

Le fichier Makefile

Voici un exemple de fichier Makefile:

all: executable

executable: mem_alloc.o main.o
	gcc -o executable main.o mem_alloc.o

mem_alloc.o: mem_alloc.c mem_alloc.h
	gcc -c mem_alloc.c

main.o: main.c mem_alloc.h
	gcc -c main.c

Si vous utilisez des espaces à la place de la tabulation, la commande make affiche le message d’erreur suivant:

Makefile:10: *** missing separator (did you mean TAB instead of 8 spaces?). Arrêt.
all: executable1 executable2

Dans ce cas, seule la cible est spécifiée. Il n’y a pas d’action à effectuer.

Règle clean

clean:
<TAB>rm -f executable1 executable2 *.o
BIN=executable
OBJETS=mem_alloc.o main.o
CFLAGS=-Wall -Werror -g
LDFLAGS=-lm

all: $(BIN)

executable: $(OBJETS)
	gcc -o executable main.o mem_alloc.o $(LDFLAGS)

mem_alloc.o: mem_alloc.c mem_alloc.h
	gcc -c mem_alloc.c $(CFLAGS)

main.o: main.c mem_alloc.h
	gcc -c main.c $(CFLAGS)

clean:
	rm -f $(BIN) $(OBJETS)

Configuration et dépendances


Pointeurs

Espace mémoire d’un processus


Adresse mémoire

* On peut faire référence à n’importe quel octet de l’espace mémoire grace à son adresse * Adresse mémoire virtuelle codée sur \(k\) bits1 * donc \(2^{k}\) octets accessibles (de 00...00 à 11...11)

Rappel: hexadécimal

Les valeurs préfixées par 0x sont représentées en hexadécimal (en base 16). Ainsi, 0x200d correspond au nombre qui s’écrit 200D en base 16, soit le nombre \(2 \times 16^3 + 0\times16^2 + 0\times16^1 + 13\times16^0 = 8205\) écrit en base 10.

La notation hexadécimale est couramment utilisée pour représenter des octets car deux chiffres en hexadécimal permettent de coder 256 (soit \(2^8\)) valeurs différentes. On peut donc représenter les 8 bits d’un octet avec deux chiffres hexadécimaux. Par exemple, 0x41 représente l’octet 0100 0001.

Architecture des processeurs

Les processeurs équipant les ordinateurs modernes sont généralement de type x86_64. Pour ces processeurs, les adresses virtuelles sont codées sur 64 bits. Un processus peut donc adresser \(2^{64}\) octets (16 Exaoctets ou 16 x 1024 Pétaoctets) différents.

ARM est une autre architecture de processeur très répandue puisqu’elle équipe la plupart des smartphones. Jusqu’à très récemment, les processeurs ARM fonctionnaient en 32 bits. Un processus pouvait donc accéder à \(2^{32}\) octets (4 Gigaoctets). Les processeurs ARM récents sont maintenant 64 bits, ce qui permet à un processus d’utiliser une plus grande quantité de mémoire.


Adresse d’une variable

  printf("adresse de var: %p\n", &var);

affiche:

adresse de var: 0x7ffe8d0cbc7f

Il est possible de manipuler l’adresse de n’importe quel objet en C, que ce soit une variable, le champ d’une structure, ou une case d’un tableau.

Le programme suivant:

#include <stdio.h>
#include <stdlib.h>

struct point{
  float x;
  float y;
  float z;
  int id;
};

int main() {
  char var='A';
  printf("adresse de var: %p\n", &var);

  struct point p = {.x = 2.5, .y = 7.2, .z=0, .id=27};
  printf("adresse de p: %p\n", &p);
  printf("adresse de p.x: %p\n", &p.x);
  printf("adresse de p.y: %p\n", &p.y);
  printf("adresse de p.z: %p\n", &p.z);
  printf("adresse de p.id: %p\n", &p.id);

  char tab[] = "hello";
  printf("adresse de tab[2] = %p\n", &tab[2]);
  return EXIT_SUCCESS;
}

peut donner cet affichage:

adresse de var: 0x7ffe44d7c6df
adresse de p: 0x7ffe44d7c6c0
adresse de p.x: 0x7ffe44d7c6c0
adresse de p.y: 0x7ffe44d7c6c4
adresse de p.z: 0x7ffe44d7c6c8
adresse de p.id: 0x7ffe44d7c6cc
adresse de tab[2] = 0x7ffe44d7c6b2

Pointeur

// pour l'exemple, les adresses sont codees sur 32 bits
char a = 'A'; // a est stocke a l'adresse 0x0000FFFF
              // la valeur de a est 0x41 ('A')
char* pa = &a; // pa est une variable de 32 bits stockee
               // aux adresses 0xFFFB a 0xFFFE
               // la valeur de pa est 0x0000FFFF (l'adresse de a)

On peut ensuite manipuler l’adresse de a (0xFFFF) ou la valeur de pa (0xFFFF) indifféremment:

      printf("&a = \%p\n", &a); // affiche 0xFFFF
      printf("pa = \%p\n", pa); // affiche 0xFFFF
      printf("&pa = \%p\n", &pa); // affiche 0xFFFB, soit l'adresse de pa

Un pointeur étant une variable comme les autres, on peut donc stocker son adresse dans un pointeur. Par exemple:

char a = 'A'; // a est stockee a l'adresse 0xFFFF et contient 0x41 ('A' ou 65)
char* pa = &a; // pa est stockee a l'adresse 0xFFFB et contient 0xFFFF (l'adresse de a)
char** ppa = &pa; // ppa est stockee a l'adresse 0xFFF7 et contient 0xFFFB (l'adresse de pa)

Conseil de vieux baroudeur

Quand vous déclarez un pointeur, initialisez-le immédiatement, soit avec l’adresse d’une variable, soit avec la valeur NULL (définie dans stdlib.h) qui est la valeur pointant sur “rien”. Dit autrement, ne laissez jamais une variable pointeur avec un contenu non initialisé.

Arithmétique de pointeur

Les opérateurs +, -, ++, et -- sont utilisables sur des pointeurs, mais avec précaution.

Incrémenter un pointeur sur type aura pour effet d’ajouter sizeof(type) à la valeur du pointeur. Par exemple:

char* pa = &a;  // pa vaut 0xFFFF
pa--;           // enleve sizeof(char) (c'est a dire 1) a pa
                // donc pa vaut 0xFFFE

char**ppa = &pa // ppa vaut 0xFFFB
ppa--;          // enleve sizeof(char*) (c'est a dire 4) a ppa
                // donc ppa vaut 0xFFF7

ppa = ppa - 2;  // soustrait 2*sizeof(char*) (donc 8) a ppa
                // ppa vaut 0xFFEF

Exemple complet

Sur https://codecast.france-ioi.org/, vous pouvez visualiser le contenu de la mémoire d’un programme. Pour cela, saisissez le code source du programme, cliquez sur “compiler”, puis exécutez le programme pas à pas en cliquant sur “next expression”. {`A} chaque instant, le contenu de la mémoire est représenté en bas de la page. Essayez avec ce programme:

#include <stdio.h>
int main() {
  //! showMemory(cursors=[a, pa, ppa], start=65528)
  char a = 'A';
  char* pa= &a;
  char** ppa = &pa;

  printf("a = %d, &a=%p\n", a, &a);
  printf("pa = %p, &pa=%p\n", pa, &pa);
  printf("ppa = %p, &ppa=%p\n", ppa, &ppa);

  pa--; // enleve sizeof(char)=1 a pa
  ppa--; // enleve sizeof(char*) a ppa

  printf("a = %d, &a=%p\n", a, &a);
  printf("pa = %p, &pa=%p\n", pa, &pa);
  printf("ppa = %p, &ppa=%p\n", ppa, &ppa);

  ppa = ppa - 2; // enleve 2*sizeof(char*) a ppa
  printf("ppa = %p, &ppa=%p\n", ppa, &ppa);

  return 0;
}

Déréférencement

A partir d’un pointeur, on peut donc afficher 3 valeurs différentes:

Déréférencement dans une structure

Lorsqu’un pointeur ptr contient l’adresse d’une structure, l’accès au champ c de la structure peut se faire en déréférençant le pointeur, puis en accédant au champ : (*ptr).c

Cela devient plus compliqué lorsque la structure contient un pointeur (p1) vers une structure qui contient un pointeur (p2) sur une structure. La syntaxe devient rapidement indigeste: (*(*(*ptr).p1).p2).c

Pour éviter cette notation complexe, on peut utiliser l’opérateur -> qui combine le déréférencement du pointeur et l’accès à un champ. Ainsi, ptr->c est équivalent à (*ptr).c. On peut donc remplacer la notation (*(*(*ptr).p1).p2).c par ptr->p1->p2->c.


Bonnes pratiques

Un pointeur doit être systématiquement initialisé au moment de sa définition

Un free sur un pointeur doit être systématiquement suivi d’une mise à NULL de ce pointeur.

Lorsqu’une fonction retourne l’adresse d’une variable, il faut que cette adresse reste valide après la fin de la fonction ! Le programme suivant illustre ce problème.

#include <stdio.h>
#include <stdlib.h>

int* get_address(int value);
void do_something(int value);

int main(int argc, char**argv) {
  // exemple de représentation mémoire du programme.
  // Au démarrage:
  //  0x7fffffffd6f0: |         |  <-- rbp (main)
  // ...
  //  0x7fffffffd6dc: | a = 42  |
  //  0x7fffffffd6d0: | v = 0   |
  //  0x7fffffffd6dc: | v = 0   |
  //  0x7fffffffd6c0: |         |  <-- rsp (main)
  
  int a = 42;
  /* Avant l'appel à get_value, le sommet de la pile est à
   * l'adresse %rsp (par exemple 0x7fffffffd6c0)
   */
  int* v = get_address(a);

  /* v récupère l'adresse de la variable locale x (0x7fffffffd6a8) */
  // 
  //  0x7fffffffd6f0: |            |  <-- rbp (main)
  // ...
  //  0x7fffffffd6dc: | a = 42     |
  //  0x7fffffffd6d0: | v = ffd6a8 |
  //  0x7fffffffd6dc: | v = 7ffff ---------------------|  
  //  0x7fffffffd6c0: |            |  <-- rsp (main)   |
  // ...                                               |
  // le reste a été désalloué, mais la mémoire         |
  // contient toujours les données                     |
  //  0x7fffffffd6b0: |            |                   |
  //  0x7fffffffd6a8: |  x = 42  <---------------------|
  
  printf("v=%p, *v=%d\n", v, *v);  // affiche "v=0x7fffffffd6a8, *v=42"

  do_something(a);

  printf("v=%p, *v=%d\n", v, *v);  // affiche "v=0x7fffffffd6a8, *v=7"

  return EXIT_SUCCESS;
}


/* Lors de l'appel à get_value, une nouvelle "frame" est créée et
 * la base de la pile (%rbp) 0x7fffffffd6b0
 */
int* get_address(int value) {
  /* La variable x est stockée à l'adress %rbp-4 = 0x7fffffffd6a8 */
  int x = value;

  //  0x7fffffffd6f0: |         |  <-- rbp (main)
  // ...
  //  0x7fffffffd6dc: | a = 42  |
  //  0x7fffffffd6d0: | v = 0   |
  //  0x7fffffffd6dc: | v = 0   |
  //  0x7fffffffd6c0: |         |  <-- rsp (main)
  // ...
  //  0x7fffffffd6b0: |         | <-- rbp (get_address)
  //  0x7fffffffd6a8: |  x = 42 |

  return &x; // valeur retournée: 0x7fffffffd6a8
}

/* Lors de l'appel à get_value, une nouvelle "frame" est créée et
 * la base de la pile (%rbp) 0x7fffffffd6b0
 * Il se trouve que cette adresse est la même que lors de l'appel à get_address 
 */
void do_something(int value) {
  /* La variable a est stockée à l'adress %rbp-4 = 0x7fffffffd6a8 */

  int a = 7;
  // 
  //  0x7fffffffd6f0: |            |  <-- rbp (main)
  // ...
  //  0x7fffffffd6dc: | a = 42     |
  //  0x7fffffffd6d0: | v = ffd6a8 |
  //  0x7fffffffd6dc: | v = 7ffff -------------------|  
  //  0x7fffffffd6c0: |            |  <-- rsp (main) |
  // ...                                             |
  //  0x7fffffffd6b0: |            |                 |
  //  0x7fffffffd6a8: |  a = 7 <---------------------|

  // ici a=7 vient écraser l'ancienne valeur de x, et donc la valeur
  // de *v

}

Tableaux et pointeurs (1/3)

Si un tableau est un argument de fonction


Tableaux et pointeurs (2/3)

Si un tableau est une variable locale ou globale


Tableaux et pointeurs (3/3)

Si un tableau est une variable locale ou globale (suite)


Passage par référence

Rappel:

void f(int* px) {
  *px = 666;         // la variable pointee par px est modifiee
}

int main() {
  int x = 42;
  f(&x);                  // l'adresse de x est donnee à f
           //  => le x de main est modifié par f
  printf("x = \%d\n", x); // la nouvelle valeur de x : 666
  return EXIT_SUCCESS;
}

Allocation dynamique de mémoire

Attention ! Risque de “fuite mémoire” si la mémoire allouée n’est jamais libérée

Signification de void*

Le void* renvoyé par malloc signifie que la fonction retourne un pointeur vers n’importe quel type de donnée. Ce pointeur (qui est donc une adresse) vers void peut être converti en pointeur (une adresse) vers int ou tout autre type.

Recommandation

Vérifiez systématiquement si malloc vous a renvoyé NULL et, si c’est le cas, arrêtez votre programme. Une manière simple et lisible de faire cela est d’utiliser la macro assert (définie dans assert.h) comme dans l’exemple suivant :

  char* str = malloc(sizeof(char)* 128);
  assert(str);

Fuites mémoire

Lorsque l’on déclare une variable (un int, un tableau, une structure, ou toute autre variable) depuis une fonction foo, l’espace mémoire de cette variable est réservé sur la pile. Lorsque l’on sort de foo, la pile est “nettoyée” et l’espace réservé pour les variables locales est libéré.

Lorsque l’on alloue de la mémoire avec malloc depuis une fonction foo, la mémoire est allouée sur le tas. Lorsque l’on sort de la fonction foo, l’espace mémoire réservé reste accessible. Si on “perd” l’emplacement de cette zone mémoire, elle devient donc inaccessible, mais reste réservée: c’est une fuite mémoire.

Si la fuite mémoire fait “perdre” quelques octets à chaque appel de la fonction foo, la mémoire de la machine risque, à terme, d’être remplie de zones inutilisées. Le système d’exploitation n’ayant plus assez de mémoire pour exécuter des processus devra donc en tuer quelques uns pour libérer de la mémoire.


Libération de mémoire

A chaque fois que vous faites free sur un pointeur, pensez à remettre ensuite ce pointeur à NULL (pour être sûr que vous n’avez pas un pointeur qui pointe sur une zone de mémoire libérée). Dit autrement, tout “free(ptr);” doit être suivi d’un “ptr = NULL;”.


Notions clés


Fichiers

Entrées-sorties bufferisées

1 Pour être exact, il s’agit de la bibliothèque standard (la libc)

Plus précisement, FILE* désigne un flux. Ce flux peut être un fichier, mais également des flux standards (stdin, stdout, ou stderr), des tubes, des sockets, etc.


Ouverture/fermeture

Après appel à la fonction fclose, f (le FILE*) devient inutilisable : le pointeur pointe vers une zone mémoire qui a peut être été libérée par fclose. Il convient donc de ne plus utiliser le fichier !


Primitives d’écriture

binaire vs. ascii

Quelle est la différence entre printf et fwrite ? La fonction fprintf écrit un ensemble de caractères ASCII dans le fichier, alors que fwrite écrit un ensemble de bits.

Ainsi, pour écrire la valeur 17 dans un fichier en ASCII, on peut exécuter:

fprintf(f, "%d", 12);

Le fichier contiendra donc les octets 0x31 (le caractère '1'), et 0x32 ('2').

Pour écrire la valeur 12 en binaire, on peut exécuter:

int n=12;
fwrite(&n, sizeof(int), 1, f);

Le fichier contiendra donc les octets 0x0C 0x00 0x00 0x00, c’est à dire les 4 octets d’un int dont la valeur est 12.

Sortie d’erreur

Puisqu’un FILE* désigne en fait un flux, on peut utiliser fprintf pour écrire sur la sortie standard d’erreur :

fprintf(stderr, "Warning: flux capacitor overlow !\n");

Exemple

Voici un programme montrant l’utilisation de primitives de lecture et d’écriture:

#include <stdio.h>
#include <stdlib.h>
#include <assert.h>

int main(int argc, char**argv) {
  if (argc != 3) {
    fprintf(stderr, "USAGE = %s fichier_source fichier_destination\n", argv[0]);
    return EXIT_FAILURE;
  }

  /* open the input and output files */
  FILE*f =fopen(argv[1], "r");
  assert(f);
  FILE*f_out =fopen(argv[2], "w");
  assert(f_out);
  char line[1024];
  int lineno = 1;
  /* read the input file line by line */
  while(fgets(line, 1024, f)) {
    /* write the line to the output file */
    fprintf(f_out, "%s", line);
    lineno++;
  }

  /* close the files */
  fclose(f);
  fclose(f_out);
  return 0;
}

Primitives de lecture

Ces 3 fonctions sont généralement utilisées chacune dans un cas précis:

int a;
float b;
fscanf(f, "%d\t%f\n", &a, &b);
struct s {
  int a;
  float b;
  char c;
};
struct s tab[10];
fread(tab, sizeof(struct s), 10, f);
char line[1024];
int lineno = 1;
while(fgets(line, 1024, f)) {
  printf("line %d: %s\n", lineno, line);
  lineno++;
}

Curseur


Debugging

Debugging

But: comprendre l’exécution d’un programme

Selon une étude1, un développeur passe 50 % de son temps à debugger des programmes. Apprendre à debugger efficacement est donc nécessaire si vous souhaitez réduire la durée de cette activité pénible.

1 T. Britton et al. Reversible debugging software. University of Cambridge-Judge Business School, 2013, Technical Report.


Debugging “manuel”


Utilisation d’un Debugger


Exemple d’utilisation de GDB

$ ./sigsegv 
Debut du programme
Erreur de segmentation

$ gdb ./sigsegv 
[...]
(gdb) run
Starting program: ./sigsegv
Debut du programme

Program received signal SIGSEGV, Segmentation fault.
0x000000000040050b in main (argc=1, argv=0x7fffffffdd68) at sigsegv.c:7
7     *ptr=5;
(gdb) print ptr
$1 = (int *) 0x0

Utiliser GDB

Vous trouverez sur https://www-inf.telecom-sudparis.eu/COURS/CSC4103/Supports/?page=annexe-gdb un récapitulatif des principales commandes gdb.

Reverse debugging

gdb est capable de faire du reverse debugging. En enregistrement ce que fait le programme, gdb peut “exécuter” le programme en arrière. Cela peut être utile pour détecter quand une variable a été modifiée.

Si vous terminez le TP rapidement, prenez le temps de regarder les vidéos suivantes qui ouvrent plein de perspectives : Reverse debugging.

Autres debuggers

Il existe d’autres debuggers que gdb, mais les principes restent les même. Si vous utilisez un IDE comme VS Code, celui-ci propose sans doute un debugger intégré.

gdb peut être étendu avec des sur-couches comme gef, ou avec des interfaces graphiques


Examiner l’état du programme

La commande bt (ou backtrace)

  (gdb) bt
#0  baz (a=2) at backtrace.c:7
#1  0x0000000000400581 in bar (n=5, m=3) at backtrace.c:15
#2  0x00000000004005ae in foo (n=4) at backtrace.c:21
#3  0x0000000000400559 in baz (a=5) at backtrace.c:9
[...]

La backtrace permet d’examiner l’état actuel du processus, ainsi que l’enchaînement d’appels de fonctions qui a mené à cet état.

  (gdb) bt
#0  baz (a=2) at backtrace.c:7
#1  0x0000000000400581 in bar (n=5, m=3) at backtrace.c:15
#2  0x00000000004005ae in foo (n=4) at backtrace.c:21
#3  0x0000000000400559 in baz (a=5) at backtrace.c:9
[...]
(gdb) frame
#0  baz (a=2) at backtrace.c:7
7     if(a<=2)
(gdb) print a
$1 = 2
(gdb) frame 1
#1  0x0000000000400581 in bar (n=5, m=3) at backtrace.c:15
15    return baz(m-1);
(gdb) print m
$2 = 3

Ici, gdb nous indique que le programme est arrêté dans la fonction baz, à la ligne 7 du fichier backtrace.c. Cette fonction a été appelée (frame #1) par la fonction bar à la ligne 15. La fonction bar a été appelée par foo à la ligne 31 (cf la frame #2).

En sélectionnant une frame, on peut examiner l’état des variables locales au site d’appel.


Etat des variables d’un processus

Il est possible de choisir le format d’affichage:

gdb peut également afficher la valeur d’un registre. Par exemple p $eax affiche la valeur du registre eax.


Exécution pas à pas

Une fois le programme lancé, possibilité d’exécuter les instructions une par une:


Points d’arrêt

Après avoir définis les points d’arrêt, on laisse le programme s’exécuter (avec la commande continue). Lorsque le programme atteint un des points d’arrêt, le débugger le met en pause et donne la main au développeur afin qu’il puisse examiner l’état du programme.

Par exemple:

$ gdb ./programme
[...]
(gdb) b bar
Breakpoint 1 at 0x400569: file programme.c, line 13.
(gdb) b backtrace.c:9
Breakpoint 2 at 0x40054c: file programme.c, line 9.
(gdb) r
Starting program: programme
Debut du programme

Breakpoint 1, bar (n=11, m=9) at backtrace.c:13
13    if(m<2)
(gdb) p n
$1 = 11
(gdb) p m
$2 = 9
(gdb) c
Continuing.

Breakpoint 2, baz (a=8) at backtrace.c:9
9     return foo(a-1);
(gdb) p a
$3 = 8
(gdb) c
Continuing.

Breakpoint 1, bar (n=8, m=6) at backtrace.c:13
13    if(m<2)
(gdb)

Il est également possible de définir des points d’arrêt conditionnels. Par exemple la commande

(gdb) b bar if n == 0

n’arrêtera l’exécution du programme en entrant dans la fonction bar que si n est égal à 0.


Surveiller une variable

Voici un exemple d’utilisation de la commande watch

$ gdb ./watch
  [...]
(gdb) watch n
 Hardware watchpoint 2: n
(gdb) c
Continuing.

Hardware watchpoint 2: n

Old value = 0
New value = 1
main (argc=1, argv=0x7fffffffdd68) at watch.c:7
7     for(i=0; i<1000; i++) {
(gdb) c
Continuing.

Hardware watchpoint 2: n

Old value = 1
New value = 2
main (argc=1, argv=0x7fffffffdd68) at watch.c:7
7     for(i=0; i<1000; i++) {
(gdb) p i
$1 = 17
[...]

Valgrind

Valgrind peut détecter l’utilisation de variables non initialisées. Par exemple, la non initialisation de n dans instructions suivantes est détectée par valgrind:

int n;
printf("%d$\n", n);
  $ valgrind ./exemple_valgrind 
==1148== Memcheck, a memory error detector
==1148== Copyright (C) 2002-2015, and GNU GPL'd, by Julian Seward et al.
==1148== Using Valgrind-3.12.0.SVN and LibVEX; rerun with -h for copyright info
==1148== Command: ./exemple_valgrind
==1148== 
==1148== Conditional jump or move depends on uninitialised value(s)
==1148==    at 0x4E7F2D3: vfprintf (vfprintf.c:1631)
==1148==    by 0x4E86AC8: printf (printf.c:33)
==1148==    by 0x400504: foo (exemple_valgrind.c:6)
==1148==    by 0x400529: main (exemple_valgrind.c:13)
==1148== 
==1148== Use of uninitialised value of size 8
==1148==    at 0x4E7C06B: _itoa_word (_itoa.c:179)
==1148==    by 0x4E7F87C: vfprintf (vfprintf.c:1631)
==1148==    by 0x4E86AC8: printf (printf.c:33)
==1148==    by 0x400504: foo (exemple_valgrind.c:6)
==1148==    by 0x400529: main (exemple_valgrind.c:13)
==1148== 
[...]
==1148== 
5
==1148== 
==1148== HEAP SUMMARY:
==1148==     in use at exit: 0 bytes in 0 blocks
==1148==   total heap usage: 1 allocs, 1 frees, 1,024 bytes allocated
==1148== 
==1148== All heap blocks were freed -- no leaks are possible
==1148== 
==1148== For counts of detected and suppressed errors, rerun with: -v
==1148== Use --track-origins=yes to see where uninitialised values come from
==1148== ERROR SUMMARY: 8 errors from 8 contexts (suppressed: 0 from 0)

Valgrind détecte également les fuites mémoire. Lorsqu’une zone mémoire allouée avec malloc() n’est pas libérée (avec free()), la zone mémoire peut être ``perdue’’. L’effet peut être grave si la fuite mémoire survient fréquemment. Par exemple, un serveur web qui perdrait quelques octets lors du traitement d’une requête web, pourrait perdre plusieurs gigaoctets de mémoire après le traitement de millions de requêtes.

Valgrind détecte ce type de fuites mémoire. Pour obtenir des informations sur l’origine de la fuite, on peut utiliser l’option --leak-check=full :

$ valgrind  --leak-check=full ./exemple_valgrind2 
==1572== Memcheck, a memory error detector
==1572== Copyright (C) 2002-2015, and GNU GPL'd, by Julian Seward et al.
==1572== Using Valgrind-3.12.0.SVN and LibVEX; rerun with -h for copyright info
==1572== Command: ./exemple_valgrind2
==1572== 
85823552
==1572== 
==1572== HEAP SUMMARY:
==1572==     in use at exit: 1,024 bytes in 1 blocks
==1572==   total heap usage: 2 allocs, 1 frees, 2,048 bytes allocated
==1572== 
==1572== 1,024 bytes in 1 blocks are definitely lost in loss record 1 of 1
==1572==    at 0x4C2BBCF: malloc (vg_replace_malloc.c:299)
==1572==    by 0x40054E: main (exemple_valgrind2.c:7)
==1572== 
==1572== LEAK SUMMARY:
==1572==    definitely lost: 1,024 bytes in 1 blocks
==1572==    indirectly lost: 0 bytes in 0 blocks
==1572==      possibly lost: 0 bytes in 0 blocks
==1572==    still reachable: 0 bytes in 0 blocks
==1572==         suppressed: 0 bytes in 0 blocks
==1572== 
==1572== For counts of detected and suppressed errors, rerun with: -v
==1572== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)

Sanitizers

Outre valgrind, vous pouvez utiliser des sanitizers pour repérer des soucis dans votre code:

Ces sanitizers sont généralement implémentés dans les principaux compilateurs (gcc, clang, visual C/C++). En fonction de votre compilateurs ou plateforme, il est possible que certains sanitizers ne soient pas disponibles. Consultez le manuel (man gcc) !


Pointeurs de fonction

1 Oui, c’est sans rapport avec le debugging, mais pour équilibrer les séances, nous avons préféré ne pas aborder cette notion lors du cours sur les pointeurs :-)

Exemple:

#include <stdio.h>
#include <stdlib.h>

double add(double a, double b) {
  return a+b;
}

double substract(double a, double b) {
  return a-b;
}

int main(int argc, char**argv) {
  double n, m;
  scanf("%lf", &n);
  scanf("%lf", &m);
  // declare a function pointer named "operation"
  double (*operation)(double, double) = NULL;

  if(n < m) {
    /* operation points to the add function */
    operation = add;
  } else {
    /* operation points to the substract function */
    operation = substract;
  }

  /* call the function pointed to by operation */
  double result = operation(n, m);

  printf("Result of the operation: %lf\n", result);

  return EXIT_SUCCESS;
}

On peut définir un type (à l’aide du mot-clé typedef) correspondant à un pointeur de fonction. Par exemple:

typedef double (*op_function)(double, double);

définit le type op_function. On peut donc ensuite déclarer un pointeur de fonction de ce type en faisant:

op_function operation;

Un plugin implémentant ce service allouera la structure et désignera ses fonctions comme callback pour le service.


Processus

Caractéristiques d’un processus

Voici un exemple de programme affichant son PID et son PPID:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main(int argc, char**argv) {
  printf("Current process ID: %d\n", getpid());
  printf("Current parent process ID: %d\n", getppid());
  return EXIT_SUCCESS;
}

Ce programme donne pour résultat:

$ ./print_id
Current process ID: 17174
Current parent process ID: 25275

Lorsque le processus parent (P1) d’un processus (P2) meurt, le processus fils est rattaché au processus au processus initial de PID 1. Le PPID de P2 devient donc 1.


Création de processus

Voici un exemple de programme utilisant la fonction system:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main(int argc, char**argv) {
  int ret_val;
  char* cmd="ps f";
  printf("Running command '%s'\n", cmd);
  printf("-----------\n");
  ret_val = system(cmd);
  printf("-----------\n");
  printf("system returned %d\n", ret_val);
  return EXIT_SUCCESS;
}

Ce programme donne pour résultat:

$ ./system
Running command 'ps f'
-----------
  PID TTY      STAT   TIME COMMAND
16847 pts/1    Ss+    0:00 bash
17076 pts/1    S      0:01  _ okular chap.pdf
 8199 pts/0    Ss+    0:00 bash
25275 pts/7    Ss     0:00 bash
 8174 pts/7    Sl     0:14  _ emacs contenu.tex
17540 pts/7    S+     0:00  _ ./system
17541 pts/7    S+     0:00      _ sh -c ps f
17542 pts/7    R+     0:00          _ ps f
-----------
system returned 0

fork

Voici un exemple d’utilisation de fork:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(int argc, char**argv) {
  printf("I am process %d. My PPID: %d\n", getpid(), getppid());

  pid_t ret_val = fork();
  if(ret_val == 0) {
    printf("I'm the child process. PID=%d, PPID=%d\n", getpid(), getppid());
    sleep(1);
  } else if (ret_val>0) {
    printf("I'm the parent process. PID=%d, PPID=%d\n", getpid(), getppid());
  } else {
    printf("Fork failed\n");
  }
  return EXIT_SUCCESS;
}

Lors du fork, le processus est intégralement dupliqué. Le processus fils possède donc la même mémoire que le processus père: on retrouve donc les variables affectées dans le processus père avant le fork.

Après le fork, les espaces mémoire des deux processus deviennent dissociés: si le processus fils (resp. le père) modifie la variable value, il ne modifie que sa copie de la variable, et le processus père (resp. le fils) ne voit pas la modification. Par exemple, le programme suivant:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(int argc, char**argv) {
  int value = 1;
  printf("[%d] Before forking. value = %d\n", getpid(), value);
  pid_t ret_val = fork();
  if(ret_val == 0) {
    value++;
    printf("[%d] After forking. value = %d\n", getpid(), value);
  } else if (ret_val>0) {
    sleep(1);
    printf("[%d] After forking. value = %d\n", getpid(), value);
  } else {
    printf("Fork failed\n");
  }
  return EXIT_SUCCESS;
}

donnera, lors de son exécution, le résultat suivant:

$ ./fork_variable
[13229] Before forking. value = 1
[13230] After forking. value = 2
[13229] After forking. value = 1

La classe de fonctions exec

Voici un exemple d’utilisation de execlp:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(int argc, char**argv) {
  printf("I am process %d. My PPID: %d\n", getpid(), getppid());

  pid_t ret_val = fork();
  if(ret_val == 0) {
    printf("I'm the child process. PID=%d, PPID=%d\n", getpid(), getppid());
    execlp("ps", "ps", "-l", NULL);
    printf("This is printed only if execlp fails\n");
    abort();
  } else if (ret_val>0) {
    printf("I'm the parent process. PID=%d, PPID=%d\n", getpid(), getppid());
    sleep(1);
  }

  return EXIT_SUCCESS;
}

exec est une famille de fonction permettant de remplacer l’image du processus courant: l’ensemble de l’espace mémoire est effacé et remplacé par l’image du procegramme exécuté.

Les paramètres peuvent être passés sous la forme d’une liste de paramètres (avec les fonctions execl*), ou sous la forme d’un tableau de chaînes de caractères (avec les fonctions execv*).


Terminaison d’un processus

Voici un exemple d’utilisation de la fonction wait :

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main(int argc, char**argv) {
  printf("I am process %d. My PPID: %d\n", getpid(), getppid());

  pid_t ret_val = fork();
  if(ret_val == 0) {
    printf("I'm the child process. PID=%d, PPID=%d\n", getpid(), getppid());
    sleep(1);
    return 17;
  } else if (ret_val>0) {
    printf("I'm the parent process. PID=%d, PPID=%d\n", getpid(), getppid());
    int status;
    pid_t pid = wait(&status);

    int exit_status = WEXITSTATUS(status);
    printf("The child process %d ended with exit status %d\n", pid, exit_status);

  } else {
    printf("Fork failed\n");
  }
  return EXIT_SUCCESS;
}

La fonction wait retourne le PID du processus fils qui s’est terminé et remplit la variable entière status. Cette variable peut ensuite être utilisée pour obtenir des informations sur la terminaison du processus fils.

Par exemple, la macro WEXITSTATUS(status) retourne le code de retour du processus fils. Ce programme donnera donc :

$ ./exemple_wait 
I am process 21088. My PPID: 20960
I'm the parent process. PID=21088, PPID=20960
I'm the child process. PID=21089, PPID=21088
The child process 21089 ended with exit status 17

La fonction waitpid est une variante de wait. Elle permet d’attendre un processus fils précis, ou de tester (sans bloquer le processus appelant) la terminaison d’un processus.


Appels systèmes

Qu’est ce qu’un système d’exploitation ?

Rôles d’un système d’exploitation:


User mode vs. Kernel mode

Cloisonnement entre le mode utilisateur et le mode noyau


Comment passer en mode noyau ?

2 méthodes:

Lorsqu’une interruption est reçue, le processeur suspend l’exécution du thread, bascule en mode noyau, et appelle la routine traitant l’interruption. Lorsque la routine se termine, le processeur rebascule en mode utilisateur est reprend l’exécution du thread.

Un appel système consiste à appeler une fonction exécuté en mode noyau. Le passage du mode utilisateur au mode noyau peut se faire en générant une interruption logicielle particulière (par exemple sur les processeurs ARM ou les processeurs x86 32 bits), ou en exécutant une instruction particulière (par exemple, l’instruction syscall sur les processeurs x86 64 bits) qui a un effet équivalent. Le kernel exécute alors la fonction correspondant au numéro de l’appel système demandé. Lorsque l’appel système se termine, on sort du traitant d’interruption, et le processeur rebascule en mode utilisateur.


Observer les appels systèmes

La commande strace intercepte et affiche les appels systèmes d’un programme:

$ strace echo "coucou"
execve("/bin/echo", ["echo", "coucou"], [/* 54 vars */]) = 0
brk(NULL)                               = 0x25d2000
access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (No such file or directory)
mmap(NULL, 12288, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f619cc01000
access("/etc/ld.so.preload", R_OK)      = -1 ENOENT (No such file or directory)
open("tls/x86_64/libc.so.6", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
open("tls/libc.so.6", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
open("x86_64/libc.so.6", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
open("libc.so.6", O_RDONLY|O_CLOEXEC)   = -1 ENOENT (No such file or directory)
open("/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
[...]
fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 2), ...}) = 0
write(1, "coucou\n", 7coucou
)                 = 7
close(1)                                = 0
close(2)                                = 0
exit_group(0)                           = ?
+++ exited with 0 +++

Gestion de la mémoire

Certains cadres de pages sont référencés par plusieurs processus. Cela est possible par exemple si les processus ne font que des accès en lecture à la page. Il s’agit typiquement du code des bibliothèques partagées (libc.so par exemple) qui peuvent être chargées par plusieurs processus.


Primitives de synchronisation: les sémaphores

Sémaphore:


Sémaphore: mise en oeuvre

L’opération sem_wait est bloquante tant qu’il n’y a pas de jeton. L’opération sem_post est, elle, non-bloquante, et permet de débloquer un des processus en attente d’un jeton.

Voici un exemple d’utilisation d’un sémaphore. Le progamme exemple_sem_init crée un sémaphore, qui peut ensuite être utilisé par le programme exemple_sem. Le programme exemple_sem_unlink détruit un sémaphore.

#include <stdio.h>
#include <stdlib.h>
#include <semaphore.h>
#include <fcntl.h>
#include <sys/stat.h>

int main(int argc, char**argv) {
  sem_t *sem;

  if (argc != 3) {
    fprintf(stderr, "USAGE = %s cle valeur\n", argv[0]);
    exit(EXIT_FAILURE);
  }

  char*cle=argv[1];
  int valeur = atoi(argv[2]);

  /* Creation et initialisation du semaphore */
  sem = sem_open(cle, O_CREAT, S_IRWXU, valeur);
  if (sem == SEM_FAILED) {
    perror("sem_open failed");
    exit(EXIT_FAILURE);
  }

  printf("Initialisation OK\n");
  int sval = -1;
  /* recupere le nombre de jetons */
  if(sem_getvalue(sem, &sval) <0) {
    perror("sem_getvalue failed");
    exit(EXIT_FAILURE);
  }
  printf("sval = %d\n", sval);

  return EXIT_SUCCESS;
}
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <semaphore.h>

int main(int argc, char**argv) {
  sem_t *sem;

  if (argc != 2) {
    fprintf(stderr, "USAGE = %s cle\n", argv[0]);
    exit(EXIT_FAILURE);
  }

  char*cle=argv[1];

  /* Creation et initialisation du semaphore */
  sem = sem_open(cle, 0);
  if (sem == SEM_FAILED) {
    perror("sem_open");
    exit(EXIT_FAILURE);
  }

  printf("Ouverture OK\n");

  printf("On prend un jeton...\n");
  sem_wait(sem);
  printf("Jeton obtenu.\n");
  sleep(5);

  printf("On relache le jeton\n");
  sem_post(sem);
  printf("Jeton relache\n");

  return EXIT_SUCCESS;
}
#include <stdio.h>
#include <stdlib.h>
#include <semaphore.h>
#include <fcntl.h>
#include <sys/stat.h>

int main(int argc, char**argv) {
  sem_t *sem;

  if (argc != 2) {
    fprintf(stderr, "USAGE = %s cle\n", argv[0]);
    exit(EXIT_FAILURE);
  }

  char*cle=argv[1];

  if(sem_unlink(cle) < 0){
    perror("sem unlink failed");
    abort();
  }

  return EXIT_SUCCESS;
}

Signaux

Signaux

Rappel (CSC3102)


Envoyer un signal

Voici un exemple de programme utilisant la fonction kill:

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/types.h>

int main(int argc, char**argv) {
  if(argc != 2) {
    fprintf(stderr, "usage: %s PID\n", argv[0]);
    return EXIT_FAILURE;
  }

  pid_t pid = atoi(argv[1]);
  int signo = SIGKILL;

  printf("Sending signal %d to %d\n", signo, pid);
  kill(pid, signo);

  return EXIT_SUCCESS;
}

Recevoir un signal

struct sigaction {
        void     (*sa_handler)(int); // pointeur sur la fonction à appeler
        void     (*sa_sigaction)(int, siginfo_t *, void *);
        sigset_t   sa_mask;
        int        sa_flags;
        void     (*sa_restorer)(void);
};

Signaux interceptables

Il est possible d’utiliser sigaction pour “intercepter” tout signal sauf les signaux SIGKILL et SIGSTOP

struct sigaction

La valeur prise par sa_handler est: * l’adresse d’une fonction (par ex: void signal_handler(int signo) * Le paramètre signo est le numéro du signal reçu * la valeur SIG_DFL pour restaurer l’action par défaut (tuer le processus) * la valeur SIG_IGN pour ignorer le signal: à la réception de ce signal, aucune action ne sera effectuée

Sauf cas d’usages particuliers, les autres champs de la structure sigaction sont à mettre à 0.

oldact

La fonction sigaction modifie le comportant du processus lorsqu’il reçoit le signal signum. Si oldact n’est pas NULL, l’ancien comportement y est stocké.

Variables globales

Si la fonction traitant le signal manipule des variables globales, il est conseillé de les déclarer volatile. Par exemple:

volatile int var;

Lorsqu’une variable est déclarée volatile, le compilateur limite les optimisations faites sur cette variable. Par exemple, le compilateur ne met pas en cache (dans un registre) la variable.

Si une fonction (par exemple foo) qui manipule la variable var non volatile est interrompue par un traitant de signal (sig_handler) modifiant var, la modification de la variable risque de ne pas être “vue” par foo qui travaille sur une copie en cache de la variable. La fonction foo risque donc de travailler sur une version obsolète de la variable.

Exemple

Voici un exemple de programme utilisant sigaction pour intercepter un signal:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/types.h>

/* Function to call upon signal reception */
void signal_handler(int signo) {
  printf("Received: signal %d\n", signo);
}

int main(int argc, char**argv) {
  if(argc != 2) {
    fprintf(stderr, "usage: %s signo\n", argv[0]);
    return EXIT_FAILURE;
  }

  /* Initialize the sigaction structure */
  int signo = atoi(argv[1]);
  struct sigaction s;
  s.sa_handler = signal_handler;

  /* Install the signal handler */
  printf("Installing signal handler for signal %d\n", signo);
  int retval = sigaction(signo, &s, NULL);
  if(retval<0) {
    perror("sigaction failed");
    abort();
  }

  /* Wait to receive signals */
  while(1) {
    printf("[%d] Sleeping...\n", getpid());
    sleep(1);
  }

  return EXIT_SUCCESS;
}

Attendre un signal


Programmer une alarme

Pour programmer une alarme avec une granularité plus fine, utilisez la fonction setitimer. Cette fonction permet de programmer des alarmes périodiques (qui se répètent) avec une granularité de l’ordre de la microseconde.

Pour aller plus loin

Les 3 sous-sections suivantes présentent des notions pour les étudiant·e·s Ninja qui ont vraiment envie d’aller encore plus loin.


sigsetjmp et siglongjmp

Permet de faire un “saut (goto) non local”

Ces fonctions peuvent être utile pour la gestion des signaux :

Elles sont également utilisées pour mettre en place des mécanismes d’exception.

Exemple : suivez le lien pour visualiser cet exemple tiré du livre “Développement système sous Linux” de Christophe BLAESS. FYI, ce livre est disponible à la médiathèque.


Programmation événementielle

libuv permet de faire de la programmation événementielle

Pour récupérer des exemples, c’est ici.

Il existe aussi d’autres bibliothèques, mais qui nous semblent moins pertinentes (vu leurs dernières dates de mise à jour): libevent et libev.


Coroutines

Cet extrait de l’exemple de tcp-echo-server de libuv (cf. libuv-1.48.0/docs/code/tcp-echo-server/main.c) illustre le problème de lisibilité:

void echo_write(uv_write_t *req, int status) {
    if (status) {
        fprintf(stderr, "Write error %s\n", uv_strerror(status));
    }
    free_write_req(req);
}

void echo_read(uv_stream_t *client, ssize_t nread, const uv_buf_t *buf) {
    if (nread > 0) {
        write_req_t *req = (write_req_t*) malloc(sizeof(write_req_t));
        req->buf = uv_buf_init(buf->base, nread);
        uv_write((uv_write_t*) req, client, &req->buf, 1, echo_write);
        return;
    }
    // ...
}

void on_new_connection(uv_stream_t *server, int status) {
    // ...
    uv_tcp_t *client = (uv_tcp_t*) malloc(sizeof(uv_tcp_t));
    uv_tcp_init(loop, client);
    if (uv_accept(server, (uv_stream_t*) client) == 0) {
        uv_read_start((uv_stream_t*) client, alloc_buffer, echo_read);
    }
    // ...
}

Ce serait bien d’avoir une seule fonction qui enchaîne ces 3 codes. Cela supposerait une fonction capable de relâcher la main en plein milieu de son exécution et reprendre à cet endroit quand on lui redonnerait la main. C’est la notion de coroutine.

Hélas, cette notion n’est pas implémentée en langage C. Mais, il existe des contournements, cf. articles que nous vous invitons à lire :


Bibliography

Annexes

Les 15 commandes à connaître pour tout padawan

Lancer/quitter GDB

Commande Description
gdb [prog] Charge le programme [prog] dans GDB.
gdb --args [prog] [arg1 arg2 ...] Charge le programme [prog] dans GDB avec les paramètres[arg1 arg2 ...]
gdb [prog] [core-file] Charge le core-dump du programme [prog] dans GDB.
q ou quit Quitte GDB.

Lancer l’exécution d’un programme

Commande Description
r ou run Lance l’exécution du programme chargé dans gdb.
r [arg1 arg2 ...] Lance l’exécution du programme chargé dans gdb avec les paramètres [arg1 arg2 ...].
set [args arg1 arg2 ...] Sélectionne la liste des arguments ([arg1 arg2 ...]) pour le prochain programme à démarrer.

Examiner l’état d’un processus

Commande Description
p [var] ou print [var] Affiche la valeur de la variable [var].
p/x [var] Affiche la valeur hexadécimale de la variable [var].
display [var] Affiche la valeur de la variable [var] à chaque arrêt du programme.
bt ou backtrace Affiche la pile d’appel.
frame Affiche la stack frame courante.
frame [x] Sélectionne la stack frame [x].
l ou list Affiche la portion de code autour de la stack frame sélectionnée.

Surveiller l’exécution d’un processus

Commande Description
b [pos] ou break [pos] Positionne un breakpoint à l’endroit [pos]. [pos] peut être un nom de fonction, un numéro de ligne (du fichier courant), un nom de fichier+numéro de ligne (break mon_fichier:57), etc.
clear [pos] Supprime le breakpoint positionné à l’endroit [pos].
d [num] ou delete [num] Supprime le breakpoint numéro [num].
w [var] ou watch [var] Surveille l’état de la variable [var].

Contrôler l’exécution d’un processus

Commande Description
n ou next Avance d’un pas.
s ou step Avance d’un pas. Si l’instruction à exécuter est un appel de fonction, ne descend pas dans la fonction.
c ou continue Continue l’exécution jusqu’au prochain breakpoint.

Pour devenir un maître jedi de GDB

Assembleur

Commande Description
disassemble [function] Affiche le code assembleur de la fonction [function].
info registers Affiche la valeur des registres.
p \$[register] Affiche la valeur d’un registre. Exemple: print \$eax.

Reverse debugging

Commande Description
record Démarre l’enregistrement du comportement du processus.
record stop Arrête l’enregistrement.
rs ou reverse-step Comme step, mais en arrière.
rn ou reverse-next Comme next, mais en arrière.
rc ou reverse-continue Comme continue, mais en arrière.
set can-use-hw-watchpoints 0 Permet d’utiliser des watchpoints en reverse debugging.

Autre

Commande Description
break [pos] if [cond] Arrête le processus à [pos] si la condition [cond] est vérifiée.
up Remonte d’une stack frame dans la pile.
down Descend d’une stack frame dans la pile.
attach [pid] Attache GDB au processus de pid [pid].
detach Détache le processus de GDB.
set env [var]=[value] Affecte la valeur [value] à la variable d’environnment [var].

Exemple de fichier Makefile

# In order to reuse this Makefile
#    Modify the project name => modify the binary name
#    Add all the .o dependencies in OBJ
#    That's all folk :)

PROJECT=main

BIN=$(PROJECT)
OBJ=main.o

# compilation flags
CFLAGS=-Wall -Werror -g
# link flags
LDFLAGS=
CC=gcc

Echo=@echo [$(PROJECT)]:

ifndef VERBOSE
  Verb := @
endif

# Tells make that 'all' and 'clean' are "virtual" targets (that does not
# generate a file)
.PHONY: all clean

all: $(BIN)

$(BIN): $(OBJ)
	$(Echo) Linking $@
	$(Verb) $(CC) $(LDFLAGS) -o $@ $^

# generic rule: to generate foo.o, we need foo.c
%.o: %.c
	$(Echo) Compiling $<
	$(Verb) $(CC) $(CFLAGS) -c "$<" -o "$@"

# you can add specific compilation rules here


# you can invoke "make clean" to delete all the generated files
clean:
	$(Echo) Cleaning compilation files
	$(Verb) rm -f $(OBJ) $(BIN)