Cours de Systèmes d'exploitation avancé

TDTP2 -- Géstion de la mémoire

A.B.Dragut et V. Risch
Vous êtes priés de rendre en fin de TP par mail:
  1. un log avec vos commandes pour chaque point des exercices à faire et
  2. une archive .tar.gz avec vos codes source.

Exo 01. Structure des fichiers executables (adresse et taille d'une variable)

Dans cet exercice

Nous analysons brièvement le format ELF pour les fichiers objet et exécutables.

Quoi de neuf :

Le format ELF divise le fichier en une zone en-tête (en-tête ELF, suivie des en-têtes des sections) et une zone avec des sections (pour les exécutables, contenues dans des segments).
Les sections sont dédiées à des informations de différentes sortes, et ont des drapeaux comme: Parmis les types de sections il y a
Le contenu d'un exécutable (comme a.out peut être regardé avec l'outil readelf. La partie de l'affichage de readelf qui nous interesse est celle-ci :
Section Headers:
   ...
Ensuite, avec l'outil od on peut regarder les sections. La section .comment est celle-ci :
000548  \0   G   C   C   :       (   G   N   U   )       4   .   2   ...

Travail à faire
  1. Écrivez un très petit programme ainsi, dans un fichier nommé Exo1.c :
  2. int main () {
      const char * enseignante = "AD";
    }
    
    Compilez et editez les liens, directement dans le fichier a.out avec
    gcc Exo1.c
    
    Regardez ensuite le contenu de a.out avec l'outil readelf, option -a, ainsi:
      readelf -a a.out
    
    Regardez attentivement la partie Section headers. On peut recuperer l'adresse (colonne Off (comme 'Offset' -- i.e. decalage en anglais) -- -- de la partie Section headers) de la section .comment . La colonne Off indique pour la section .comment l'adresse 000548, et on regarde son contenu avec l'outil od ainsi :
      od -A x -c -j 0x548 a.out | less
    
  3. D'après vous, dans quelle section (.data, .rodata, .bss) se trouvera la variable enseignante ? Refaites tout le chemin précedent (readelf, od, etc.) pour lire le contenu de la variable enseignante également. Trouvez l'adresse du segment mémoire de la section. Calculez le début de l'adresse et aussi la taille de la zone mémoire occupée par cette variable.
  4. Rajoutez dans votre programme la variable enseignant
  5. const char * enseignant = "VR";
    
    Calculez le début de l'adresse de la zone mémoire occupée par cette nouvelle variable.
  6. Rajoutez dans votre programme la variable
  7. const unsigned int AgeEnseignante=40;
    
    Calculez le début de l'adresse et aussi la taille de la zone mémoire occupée par cette variable. Comparez la taille de variable AgeEnseignante avec la taille de la variable enseignante. Sont-elles pareilles ? Pourquoi?
  8. Peut-on obtenir directement les addresses sans avoir besoin des calculs? Oui si votre exécutable n'a pas été "déshabillé " (stripped en anglais)
  9. La commande nm (noms de variables) liste les symboles des fichiers objets donnés en argument. Si aucun fichier objet n'est donné comme argument, nm suppose que c'est `a.out'.
  10. Compilez avec le niveau d'optimisation -O3 qui est le plus haut niveau d'optimisation possible (un drapeau -O4 n'a aucun effet) mais aussi du plus grand risque. Le temps de compilation sera plus long avec cette option qui en fait ne devrait pas être utilisée de façon globale à partir de GCC 4.x.
  11.   gcc -O3 -m32 Exo1.c 
    
    1. Testez et verifiez si vos calculs précédents sont corrects.
    2.    nm -f sysv Exo1.run
      
      L'affichage ressemble à ceci :
      Symbols from Exo1.run:
      
      
      Name Value Class Type Size Line Section
      
      enseignante |..........| D | OBJECT|........| |.data
      
      Le champ Value donne l'adresse et non pas le contenu de la variable.
    3. Executez
    4.      strip -s Exo1.run
           nm -f sysv Exo1.run
      
      Le resultat doit être
      nm: a.out: no symbols
      
    5. Comparez la taille d'Exo1 avant et apres le "strip" et répondez à la question: "Pourquoi "deshabille"-t-on les exécutables?"
    6. Quel est le resultat de nm sur /bin/* ?
    7. Refaites le même exercice mais avec les variables déclarées globales, donc avant la fonction main() Que voyez-vous ? Avec certaines versions de gcc les variables apparaissent explicitement.

Exo 02. Placer les variables du programme dans de sections mémoire d'un processus

Quoi de neuf :

La segmentation divise la mémoire virtuelle d'un processus dans des parties reliées logiquement, qui varient en taille. Il y a des segments mémoire de taille fixe et de taille variable.
La taille de la pile est connue d'avance. Son placement exact varie d'une version d'UNIX à l'autre. Par rapport aux sections du format ELF, les segments mémoire permettent une meilleure vérification et élimination des erreurs.
Il y a trois variables entières globales dans la bibliothèque C pour les manipuler: etext, edata, et end (faites man 3 end pour des détails). Ces variables indiquent les frontières de trois segments : La valeur de la variable etext est une borne supérieure de la taille de l'image exécutable.
Deux fonctions (système et bibliothèque) permettent de réajuster la taille du tas (heap), c'est-à-dire l'allocation dynamique de la mémoire.
#include <unistd.h>
int brk(void *nvelleAdresseFin);
void *sbrk(intptr_t deplacement);
Les deux sont obsolètes en tant que fonctions pour les programmeurs "utilisateur" -- elles sont censées être utilisées uniquement pour le développement du noyau.
brk() change directement l'address et renvoie 0 si succès sbrk() ajoute un déplacement (y compris 0) et renvoie l'adresse de ébut de la nouvelle zone.
Travail à faire
  1. Récupérez le programme. Compilez et testez-le.
  2. Complétez le diagrame suivant
  3.        +------------------+
           |  TEXT            | x
           |                  |
           |  instructions    | x 
           |  code machine    | 
           |                  |
           +------------------+ x      = etext
           |  DATA            | x      = globales initialisees
           |  - variables     | 
           |    initialisees  | x      = statiques locales initialisees
           |                  | 
           |                  | x      = edata
           |                  | x      = statiques locales non-initialisees
           |                  | 
           | ---------------  |
           |  - variables     | x      = 
           |  noninitialisees | x      = globales noninitialisees
           |                  | 
           |                  |
           |                  | 
           | ---------------  | 
           |  - tas pour      | x      = pointe par
           |    l'allocation  | x	   = pointe par
           |    dynamique     |
           |                  |
           |                  |
           +------------------+ x	   = fin segment(s) données
                    |
                    |
                    V
    
                    .
                    .
                    .
    
                    ^
                    |
                    |
           +------------------+
           |  PILE            |  
           |  - fonction      | x         = init local | Instruction empilee
           |                  | x         = init local | pour PtrFct
           |                  |
           |  - variables     | x         = loc        | Instruction empilee
           |    locales       |                        | pour main()
           |    automatiques  |         
           +------------------+
    
  4. Calculez la fin du tas/heap qui est la fin du segment de données (data segment) et rajoutez dans le programme un appel à sbrk(0) pour vérifier vos calculs.
  5. Optionnels

  6. Trouver l'adresse d'une variable. Est-elle là où l'on s'y attend? Non, il y a du "padding".
  7. Quoi de neuf :

    Les emplacements mémoire pour les variables sont alignés et paddé en fonction de leur type (donc taille) Donc lorsqu'on déclare des membres de différent types dans une structure, le compilateur s'arrange pour respecter ces alignements en mettant éventuellement du "padding".
    Travail à faire
    Soit une structure C++ appelée MaStruct à quatre membres~: deux bool, un int et un unsigned short
    1. Quelle est la taille minimale de cette structure ? (Indication: la somme des tailles des membres)
    2. Soit la définition de la structure MaStruct suivante :
    3. struct MaStruct {
          bool           qDrapeau;
          unsigned short nShort;
          bool           qAutreDrapeau;
          int            nInt;
      };
      
      et considérons-la placée en mémoire à partir de zéro. Desinnez chaque variable, et les octets de padding nécessaires, respectant les règles énoncées plus haut, ainsi
      
          |          |          |                   |
          | qDrapeau |  PADDING |       nShort      |    ....
          +----------+----------+---------+---------+--- ....
          0          1          2         3         4    ....
      
      sachant que le tout occupera douze octets (pourquoi?).
    4. Montrez-le, en écrivant le programme suivant dans le fichier exo2OptPad.cxx
    5. #include <iostream>
      
      struct MaStruct {
          bool           qDrapeau;
          unsigned short nShort;
          bool           qAutreDrapeau;
          int            nInt;
      };
      
      int main(void) {
          MaStruct maStruct;
          std::cout << "taille: " << sizeof(maStruct) << "\n";
          return(0);
      }
      
      et en le compilant et exécutant.
    6. Recopiez le fichier exo2OptPad.cxx dans exo2OptPad2.cxx, et changez l'ordre des données membres dans la définition, pour obtenir la taille minimale lors de l'exécution. (Indication : taille minimale veut dire absence de padding. Absence de padding veut dire alignement "parfait" des données-membres dès qu'on les met simplement les unes à la suite des autres. Alignement "parfait" veut dire respect des règles, et celles-ci font aussi que l'adresse APRÈS une variable de taille T soit multiple de T, puisque n*T + 1 = (n+1)*T. Enfin, observez que les multiples de 4 sont aussi des multiples de 2, et que les multiples de 2 sont aussi des multiples de 1.). Montrez qu'on obtient en effet cette taille minimale en compilant et exécutant ce programme.
  8. Pointeurs-racine pour les "ramasse-miettes" (garbage collector) dans des programmes single-thread
  9. Le traçage des "ramasse-miettes" commence avec la collection des "pointeurs racine" (root pointers). Les pointeurs racine sont exactement ceux qui pointent vers les données allouées. Les pointeurs racine peuvent être trouvés sur la pile, dans des régions globales ou bien dans les registres.
    Bibliographie : l'algorithme "mark and sweep" et les rammasse-miettes peuvent être utilisés comme déteacteurs de fuites mémoire

Exo 03. Changements dans les variables d'un exécutable

Quoi de neuf :

On approfondit l'utilisation de readelf et d'od et on modifie directement un fichier exécutable après en avoir analysé le contenu et la structure.
Travail à faire
  1. Écrivez dans un fichier exo3Ini.cxx le petit programme C++ suivant
  2. #include <iomanip>
    #include <iostream>
    
    using namespace std;
    
    int main (int argc, char * argv []) {
        const char * enseignant = "AD";
        cout << enseignant << endl;
        return 0;
    }
    
  3. Compilez-le avec
       g++ -o exo3Ini.run exo3Ini.cxx
    
    et exécutez-le -- vous devez bien entendu voir la sortie AD.
  4. Dans quelle section ELF se trouvera la variable enseignant, sachant qu'elle donc constante et initialisé ?
  5. Cherchez-en l'adresse dans exo3Ini.run avec la commande
  6.     readelf -a exo3Ini.run
    
    (attention -- bien entendu avec cette commande vous n'allez pas retrouver directement le nom de variable 'enseignant', ce n'est pas ainsi qu'on trouve la réponse).
  7. Vérifiez maintenant la présence de la chaîne de caractès "AD" avec la commande
        od -A x -c -j 0xAdresseTrouveeParVous a.out | head
    
    (donc en y mettant la valeur trouvée au point précédent). Vous devez voir quelque chose du genre
      .... A   D  \0   .....
    
  8. Déduisez l'adresse exacte où commence la chaîne (donc où se trouve le 'A' de "AD") et vérifiez en relançant la commande od comme plus haut, mais avec cette valeur plus précise. Supposons que l'adresse en question est 0x8bd, vous devez alors voir
  9. 0008bd   A   D  \0  .....
    ........... 
    
  10. Écrivez dans le fichier exo3.cxx un autre petit programme C++ qui utilise des fonctions système pour modifier le fichier exécutable exo3Ini.run afin qu'ensuite l'exécution de exo3Ini.run affiche "VR" (donc au lieu d'afficher, comme avant, "AD"). Il faut donc ouvrir exo3Ini.run en écriture (c'est bien un fichier, après tout, qui nous appartient, donc on peut bien faire cela) et écrire "ce qu'il faut", mais au bon endroit -- utilisant donc le travail des points précédents.
  11. Compilez exo3.cxx en exo3.run avec
  12.    g++ -o exo3.run exo3.cxx
    
  13. Testez le tout, en lançant
    • la compilation de exo3Ini.cxx en exo3Ini.run
    • l'exécution de exo3Ini.run -- vous devez voir "AD"
    • l'exécution de exo3.run
    • une autre exécution de exo3Ini.run -- vous devez voir "VR"
  14. Trouvez entre quelles adresses en hexadecimal est comprise la variable enseignant. Quelle est sa taille en octets ?

Exo 04. exec() vide les segments mémoire d'un processus

Quoi de neuf :

Un exécutable peut avoir l'air de bien fonctionner, sans aucun problème apparent. Néanmoins, un programme peut comporter bien des fuites de mémoire qui mettent en danger le système si le programme tourne de manière cyclique. Apres execve(), le man nous dit qu'on met à zéro le tas, le .data, .rodata, etc. Que se passe-t-il avec le tas?
Travail à faire
Le but du jeu ici est d'utiliser strace -f pour voir l'enchaînement des appels système et les changements successifs des zones mémoire du processus durant son exécution, regardant dans /proc/LePIDduProcFils/maps au fur et à mesure qu'on l'exécute. C'est pour cela que la solution de cet exercice est parsemée de sleep() (et de plus, ceux-ci sont bien visibles avec strace en tant qu'appels système à nanosleep()).
Enfin, pour une analyse "pure" avec strace on se prend un X trè simple et "gentil" à execve()er. Téléchargez l'archive de travail depuis
ici. Dedans vous trouverez la structure habituelle des exercices de TP, et notamment deux programmes principaux. Occupons-nous d'abord du plus petit et simple -- le m.c, qui contient déjà ceci :
int main() {
    sleep(30);
    return(0);
}
Compilez-le en m.run. Ainsi, ceci sera notre X (sans arguments).
Regardons en détail le code de exo4.cxx, qui est une des variantes de la solution de l'exercice du TP Processus portant sur execve(). Notamment, le premier appel à new engendrera à un appel système à brk(unGrandNombreEnHexa) (visible bien entendu avec strace -f). Une fois cet appel exécuté, dans /proc/LePIDduProcFils/maps on pourra alors voir effectivement apparaître une ligne avec le mot heap commençant exactement à l'adresse donné en retour de l'appel système à brk(unGrandNombreEnHexa).
La suite de ces explications est à lire deux fois: une fois vous ne faites que la lire, pour comprendre. Ensuite, vous pouvez refaire ces manipulations pas à pas, et donc en relisant le texte qui suit.
  1. On ouvre deux terminaux. Dans un terminal on lance
  2.    strace -f exo4.run 'm.run' 2>&1 | tee log.txt
    
    et on commence à voir des choses du genre
    [pid 19630] write(2, "My pid is ", 10My pid is )  = 10
    [pid 19630] write(2, "19630", 519630)        = 5
    [pid 19630] write(2, "\n", 1
    )           = 1
    [pid 19630] rt_sigprocmask(SIG_BLOCK, [CHLD], [], 8) = 0
    [pid 19630] rt_sigaction(SIGCHLD, NULL, {SIG_DFL, [], 0}, 8) = 0
    [pid 19630] rt_sigprocmask(SIG_SETMASK, [], NULL, 8) = 0
    [pid 19630] nanosleep({1, 0}, 0x7ffff2801c20) = 0
    [pid 19630] rt_sigprocmask(SIG_BLOCK, [CHLD], [], 8) = 0
    [pid 19630] rt_sigaction(SIGCHLD, NULL, {SIG_DFL, [], 0}, 8) = 0
    [pid 19630] rt_sigprocmask(SIG_SETMASK, [], NULL, 8) = 0
    [pid 19630] nanosleep({2, 0}, 0x7ffff2801c20) = 0
    
  3. Alors on récupère ainsi le pid (19630) et "vite-vite" on va dans l'autre terminal et on fait
  4.    less /proc/19630/maps
    
    et on voit plein de lignes de ce fichier-là avec les zones mémoires dont le noyau a muni notre processus
    00400000-00403000 r-xp 00000000 08:07 14344203                           /home/....
    7fc087321000-7fc087477000 r-xp 00000000 08:06 156058                     /lib64/libc-2.11.2.so
    ...
    7fffec018000-7fffec039000 rw-p 00000000 00:00 0                          [stack]
    ...
    
    Par contre, on ne voit pas de ligne avec le mot heap (pas encore).
  5. Quittons alors le less (avec la touche q) dans cet autre terminal (gardons le terminal ouvert).
  6. Revenons à l'autre terminal et continuons à regarder le déroulement :
  7. ...
    [pid 19630] nanosleep({3, 0}, 0x7ffff2801c20) = 0
    [pid 19630] rt_sigprocmask(SIG_BLOCK, [CHLD], [], 8) = 0
    [pid 19630] rt_sigaction(SIGCHLD, NULL, {SIG_DFL, [], 0}, 8) = 0
    [pid 19630] rt_sigprocmask(SIG_SETMASK, [], NULL, 8) = 0
    [pid 19630] nanosleep({4, 0}, 0x7ffff2801c20) = 0
    [pid 19630] rt_sigprocmask(SIG_BLOCK, [CHLD], [], 8) = 0
    [pid 19630] rt_sigaction(SIGCHLD, NULL, {SIG_DFL, [], 0}, 8) = 0
    [pid 19630] rt_sigprocmask(SIG_SETMASK, [], NULL, 8) = 0
    [pid 19630] nanosleep({1, 0}, 0x7ffff2801c20) = 0
    [pid 19630] brk(0)                      = 0x605000
    [pid 19630] brk(0x626000)               = 0x626000
    [pid 19630] rt_sigprocmask(SIG_BLOCK, [CHLD], [], 8) = 0
    [pid 19630] rt_sigaction(SIGCHLD, NULL, {SIG_DFL, [], 0}, 8) = 0
    [pid 19630] rt_sigprocmask(SIG_SETMASK, [], NULL, 8) = 0
    [pid 19630] nanosleep({1, 0}, 0x7ffff2801c20) = 0
    
  8. Nous venons de voir le brk(unGrandNombreEnHexa) en question.
  9. Regardons vite notre /proc/19630/maps à nouveau (juste touche Flêche-en-haut et ensuite touche Entrée (si vous utilisez bash), pour rappeler la dernière commande dans l'autre terminal). Cette fois on voit bien une nouvelle ligne
  10. 00605000-00626000 rw-p 00000000 00:00 0                                  [heap]
    
    qui est apparue suite à l'exécution du brk(0x626000). (Si on ne l'aperçoit pas tout de suite "à l'oeil nu", on peut la chercher en tapant /heap (le / annonce à less qu'on souhaite qu'il nous cherche une chaîne de caractères dans le fichier en cours d'examination)).
  11. Quittons à nouveau le less et revenons à l'autre terminal avec l'exécution.
  12. On y voit par la suite apparaître d'autres nanosleep(), etc., et enfin l'execve() de notre m.run, suivi d'un nouveau brk(0), et après plein d'autres appels, on voit enfin un nanosleep(30), qui est celui du programme m.c.
  13. [pid 19630] execve("/home/.../m.run", ...) = 0
    [pid 19630] brk(0)                      = 0x602000
    [pid 19630] mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7eff6fc8a0
    ...
    [pid 19630] rt_sigprocmask(SIG_SETMASK, [], NULL, 8) = 0
    [pid 19630] nanosleep({30, 0}, 0x7ffff3e98f50) = 0
    
  14. Une dernière fois on va alors vite dans l'autre terminal et on regarde à nouveau notre /proc/19630/maps, et là on voit un paysage bien différent. Les premières lignes maintenant parlent de m.run (et non plus d'exo4.run), et il n'y a plus de ligne avec heap.
Vous pouvez remplacer les less par des cat et même sauvegarder dans des fichiers leur résultats. Ainsi vous aurez le déroulement complet, ensemble avec log.txt sauvegardé avec le tee comme suggéré plus haut et vous allez ensuite pouvoir réexaminer le déroulement pour mieux comprendre le tout.

Exo 05. Allouer des segments de mémoire partagée

Dans cet exercice

On met en oeuvre le mécanisme de mémoire partagée d'une manière simple, pour en illustrer le principe de base de son utilisation. On écrit un programme qui demande une zone de mémoire partagée suffisamment grande pour y loger une variable entière, se duplique, et le fils incrémente cette variable entière un nombre de fois. Le père affiche la valeur de la variable avant et après, et on doit ainsi constater qu'elle a bien changé.

Quoi de neuf :

Les wrappers pour acceder à la mémoire partagée sont
Travail à faire
  1. Téléchargez l'archive de travail depuis ici. Dedans vous trouverez la structure habituelle des exercices de TP.
  2. Dans exo5.cxx
  3. Compilez et testez, vérifiant bien en regardant dans /proc/meminfo qu'on libère ce qu'il faut.

Exo 06. Utilisation de mmap()

Dans cet exercice

On illustre l'utilisation de mmap(), avec un programme similaire à celui de l'exercice 5.

Quoi de neuf :

L'appel système mmap() permet de mapper des zones de fichiers directement en mémoire, et, si le drapeau MAP_SHARED est è configurè, alors la zone mèmoire sera partagèe (d'habitude entre processus apparentès).
Par ailleurs, le système offre certains fichiers et "périphériques" spéciaux, comme par exemple /dev/zero. Celui-ci est une source infinie d'octets nuls en lecture, acceptant également n'importe quelle valeur en écriture (et l'ignorant).
Si on ouvre alors ce périphérique spécial en lecture/écriture (avec l'appel système open() "classique") et on le mappe en MAP_SHARED> avec mmap() (pouvant ensuite même fermer le périphérique avec close()), ceci nous fournit eégalement un méchanisme de mémoire partagée entre tous ces processus et tous ses descendants.
Le wrapper pour mapper la mémoire est
Travail à faire
  1. Téléchargez l'archive de travail depuis ici. Dedans vous trouverez la structure habituelle des exercices de TP.
  2. Dans exo6.cxx
  3. reprenez l'espace anonyme de exo5.cxx, mais enlevez l'appel à Shmdt()
  4. dans le try-catch du main()

Exo 07. ptrace() et le changement dans les instructions d'un exécutable --

Dans cet exercice

On met en oeuvre le principe de base d'un débogueur/virus, avec l'appel systèeme ptrace() et la manipulation des registres CPU.

Quoi de neuf :

Présentation de l'appel systèeme ptrace()

L'appel système ptrace() sert essentiellement dans le debogage. Il permet à un processus parent d'interrompre un autre processus, de le faire tourner pas-à-pas, de l'inspecter et même de le modifier. Le processus parent traceur utilise wait() pour être informé de chaque moment où le processus tracé est ainsi interrompu. Le traceur peut alors donc l'inspecter, le faire tourner "encore un peu", le modifier, etc. et l'attendre à nouveau avec wait() pour itérer ainsi.
Afin de pouvoir utiliser pleinement ptrace() il faut également une connaissance de l'architecture du CPU qu'on utilise. Les requêtes de ptrace() sont Il y a deux façons de s'attacher :
  1. PTRACE_TRACEME : Le processus parent traceur fait d'abord un fork(), et dans le fils, ptrace() est appellé donc ainsi :
  2. ptrace(PTRACE_TRACEME, 0 ,NULL, NULL);
    
    Le père doit faire wait() pour que le noyau l'informe ainsi quand le fils tracé est réellement interrompu. Ensuite (bien entendu si wait() ne dit pas qu'en fait le fils tracé est maintenant mort), le père peut utiliser les autres requêtes de ptrace().
  3. PTRACE_ATTACH : Le processus traceur s'attache à un processus déjà existant, en stipulant la requête PTRACE_ATTACH ainsi que le PID du processus auquel on souhaite s'attacher.
  4. ptrace(PTRACE_ATTACH, PID ,NULL, NULL);
    
    Si cet appel réussit, le processus auquel le traceur s'est ainsi attaché commence à se comporter "comme un fils" du traceur (sauf que getppid() donne toujours le PID de son vrai père), et donc comme pour le PTRACE_TRACEME. Cet appel ne réussit pas si le processus qu'on souhaite tracer est déjà en train d'être tracé.
Vie du processus tracé (suivi) :
Une fois que le processus commence à être suivi, il s'arrêtera à chaque fois qu'un signal lui sera délivré, meme si le signal est ignoré (à l'exception de SIGKILL qui a les effets habituels). Ainsi, si un masque est actif, on suit le processus même dans le traitant du signal. On peut également lui faire ignorer le signal, lui envoyer un autre signal ou meme exécuter un tout autre traitant ! Et le scheduler redonne donc la main au processus traceur, qui sera prevenu au premier wait(). Il pourra alors inspecter et/ou modifier le processus fils pendant son arrêt.
De plus, un processus suivi reçoit automatiquement un signal SIGTRAP lorsqu'il fait l'appel systeme execve(), donc le traceur peut dans ce cas aussi en être prevenu avec un wait(). Enfin, si le traceur utilise par exemple PTRACE_SINGLESTEP, alors le processus suivi exécute une seule instruction, et le traceur peut à nouveau utiliser wait() pour savoir quand le processus suivi a fini cette instruction. Dans tous ces cas, le traceur utilise donc wait() (et les macros connues -- WIFSTOPPED, WSTOPSIG, etc.) pour "savoir ce qui se passe".
Quand le traceur a finit de tracer le fils, il peut le libérer avec PTRACE_DETACH ou le tuer avec PTRACE_KILL. De même, le traceur sera aussi inform&eauite; si son fils tracé est mort, avec par exemple wait() (et les macros habituelles).

Manipulation des registres CPU avec l'appel systèeme ptrace() Les registres CPU sont des "petits bouts de mémoire", et ils sont directement logés sur le processeur. Ils servent à contenir les opérandes et résultats des opérations arithmétiques, logiques, de lecture/écriture dans la mémoire, etc. effectuées par le processeur. Pour un processus donné, les valeurs de ces registres font partie de qu'on appelle le contexte du processus (qu'on sauvegarde lors des changements de contexte, pour les retrouver quand on reprend l'exécution du processus).
Chaque type de processeur (architecture) a son propre jeu de registres et conventions pour leur utilisation. Ainsi, le code du noyau et les appels système ont également des implémentations spécifiques en termes d'utilisation des registres. Typiquement les processeurs ont des registres à usage général, ainsi que des registres spéciaux, comme le pointeur d'instructions et celui de pile.
Pour les processeurs Intel x86 Chaque appel système (comme read(), write(), etc.) est identifié par un code numérique. Ce code est accessible en C/C++ puisqu'il est défini symboliquement dans les fichiers d'en-tête système, typiquement sous la forme SYS_NomDeLAppel : Donc par exemple pour un appel système à write(), la valeur SYS_write sera mise dans eax et le paramètre nombre d'octets à écrire sera mis dans edx:
#include 
#include 
const char str[] = "Ca va\n les enfants?\n";
int main() {
  write(1, str, strlen(str));
  return 0;
}

L'appel système ptrace() avec PTRACE_GETREGS remplit une structure spéciale nomméee user_regs_struct avec les valeurs des registres. La définition de cette structure est bien entendu dans les fichiers d'en-tête système (et change d'une architecture à l'autre). Pour x86 32 bit on retrouve comme champs exactement eax, edx etc., ainsi que le champ orig_eax. Pour x86 64bit, le 'e' est remplacé par 'r': rip, rsp, rax, rdx, etc.
Lors d'une interruption de traçage qui survient quand le processus suivi est sur le point de faire un appel système, quand on fait ptrace() avec PTRACE_GETREGS, c'est donc orig_eax qui est rempli avec la bonne valeur SYS_NomDeLAppel de l'appel. On peut ainsi savoir quel appel système allait être exécuté. Enfin, si on met des valeurs dans ces champs et qu'on appelle ensuite ptrace() avec PTRACE_SETREGS, on arrive à modifier les valeurs se trouvant dans les registres du CPU pour le contexte du processus tracé, donc lorsque l'exécution de celui-ci est reprise, il trouvera ces valeurs-là changées "à son insu". Cceci peut ou pas être "grave" -- par exemple, lors de son déboguage, on peut légitimement souhaiter changer une variable du programme, et si celle-ci se trouve dans un registre, on aura donc bien besoin de changer la valeur de ce registre.
Pour compléter la discussion, il faut mentionner l'appel système syscall() qui reçoit comme argument justement la valeur donnant le code d'un appel système souhaité (donc comme SYS_write, ou SYS_read, etc.) et exécute cet appel (il faut bien entendu avoir proprement mis ses paramèetres dans les registres, etc.). (Ceci est d'ailleurs exactement une partie du rôle des wrappeurs des appels système, pour qu'on puisse les utiliser depuis des programmes C sans avoir à manipuler des registres).
Enfin, il faut noter qu'on peut également manipuler la pile, à l'aide des registres eip et esp pour faire un processus exécuter du code arbitraire : on peut écrire le code machine souhaité sur la pile (donc là où esp pointe dans la mémoire, on sauvegarde le eip sur la pile (pour avoir comment revenir au code initial), et on écrase la valeur d'eip avec la valeur d'esp. Ainsi, le flot d'exécution des instructions arrive sur la pile sur le code arbitraire ainsi mis. Ceci ne fonctionne pas si le système protège la pile en la rendand non-exécutable.
Travail à faire
  1. Créez un processus père qui compte les instructions asm effectuées par son fils qui fait exec() d'un utilitaire (e.g. /bin/ls), utilisant bien entendu PTRACE_TRACEME dans le fils, et wait() dans une boucle avec PTRACE_SINGLESTEP dans le père.
  2. Comment peut-on faire en sorte qu'un processus ne puisse pas être tracé ?
  3. Sauvegardez le petit programme ci-dessus qui tente d'écrire "Ca va\n les enfants?\n" sous le nom exo7f.c, compilez-le
       gcc -o exo7.run exo7.c
    
    et testez-le. Écrivez un petit programme qui se duplique et dont le fils et tracé par son père et fait exec() de exo7f.run. Le père fait en sorte que le fils n'affiche pas toute la chaîne mais seulement les cinq premiers caractères.