Surveiller les appels de fonctions

Gazette Linux n°151 — Juin 2008

Aurelian Melinte

Traduction: Nicolas Provost

Relecture de la version française : Deny

Article paru dans le n°151 de la Gazette Linux de Juin 2008.

Article publié sous Open Publication License. La Linux Gazette n'est ni produite, ni sponsorisée, ni avalisée par notre hébergeur principal, SSC, Inc.


Table des matières

Aperçu
Code d'instrumentation
Binutils et libbfd
Structure de la pile
Suivi des arguments
Valeurs de retour des fonctions
Exemple de sortie
Ressources

Aperçu

Il existe des outils pour surveiller les appels systèmes effectués par une application, mais comment écrire vos propres fonctions de surveillance à l'intérieur du programme lui-même ? Comment faire si nous voulons savoir quand une fonction est exécutée, avec quels arguments elle est invoquée, quand la fonction se termine et avec quelle valeur de retour ? Cet article présente un outil proof-of-concept pour réaliser ceci sans modifier le code de l'application.

Bien que le compilateur gcc se charge du code d'instrumentation pour nous, quelques points sont laissés au programmeur et dépendent à la fois de la version du compilateur et du CPU, principalement pour la récupération des arguments d'une fonction et des valeurs de retour.

Code d'instrumentation

Nous voulons aborder les points suivants :

  • quand entre-t-on ou sort-on d'une fonction/d'une méthode

  • que valaient les arguments à l'entrée de la fonction

  • quel a été le code retourné à la sortie de la fonction

  • éventuellement, depuis où la fonction a t-elle été appelée

Le premier point est simple ; à la demande, le compilateur va générer l'instrumentation de fonctions et méthodes, de telle façon que quand on entre ou on sort d'une fonction ou d'une méthode, un appel à une fonction d'instrumentation est effectué :

    void __cyg_profile_func_enter(void *fonction, void *lieu_appel); 
    void __cyg_profile_func_exit(void *fonction, void *lieu_appel); 

Ceci est réalisé en compilant le code avec l'option -finstrument-functions. Les deux fonctions ci-dessus peuvent être utilisées, par exemple, pour collecter des données à des fins de journalisation ou de profilage. Nous les utiliserons pour suivre les appels de fonctions. De plus, nous pouvons placer ces fonctions et le code dans une librairie intermédiaire personnelle. Cette librairie peut être chargée quand (et si) nécessaire, ce qui laisse le code de l'application globalement inchangé.

Maintenant, quand on entre dans la fonction, nous obtenons les arguments d'appel :

 void __cyg_profile_func_enter( void *fonction, void *lieu_appel )
    {
        char buf_func[CTRACE_BUF_LEN+1] = {0};
        char buf_file[CTRACE_BUF_LEN+1] = {0}; 
        char buf_args[ARG_BUF_LEN + 1] = {0}; 
        pthread_t self = (pthread_t)0;
        int *frame = NULL;
        int nargs = 0;
    
        self = pthread_self(); 
        frame = (int *)__builtin_frame_address(1); /*de la 'fonction'*/

        /*Quelle fonction*/
        libtrace_resolve (fonction, buf_func, CTRACE_BUF_LEN, NULL, 0);
        
        /*Depuis où.  KO si optimisations. */
        libtrace_resolve (lieu_appel, NULL, 0, buf_file, CTRACE_BUF_LEN);
        
        nargs = nchr(buf_func, ',') + 1; /*le dernier argument n'a pas de virgule derrière*/ 
        nargs += is_cpp(buf_func);       /*'this'*/
        if (nargs > MAX_ARG_SHOW)
            nargs = MAX_ARG_SHOW;

        printf("T%p: %p %s %s [depuis %s]\n", 
               self, (int*)fonction, buf_func, 
               args(buf_args, ARG_BUF_LEN, nargs, frame), 
               buf_file);
    }

Et en sortie de fonction, nous obtenons la valeur de retour :

  void __cyg_profile_func_exit( void *fonction, void *lieu_appel )
    {
        long ret = 0L; 
        char buf_func[CTRACE_BUF_LEN+1] = {0};
        char buf_file[CTRACE_BUF_LEN+1] = {0}; 
        pthread_t self = (pthread_t)0;

        GET_EBX(ret); 
        self = pthread_self(); 

        /*Quelle fonction*/
        libtrace_resolve (fonction, buf_func, CTRACE_BUF_LEN, NULL, 0);
        
        printf("T%p: %p %s => %d\n", 
               self, (int*)fonction, buf_func, 
               ret);

        SET_EBX(ret); 
    }

Comme ces deux fonctions d'instrumentation n'ont pas connaissance des adresses, et que nous souhaitons que les traces soient compréhensibles pour des humains, nous devons également convertir les adresses des symboles en leurs noms, c'est ce que réalise libtrace_resolve().

Binutils et libbfd

Tout d'abord, nous devons récupérer les informations symboliques utiles. Pour cela, nous compilons notre application avec l'option -g. Nous pouvons alors associer les adresses aux noms symboliques. Ceci aurait normalement imposé l'écriture de code non compatible avec le format ELF.

Par chance, le paquet binutils existe, qui provient d'une librairie qui réalise justement cela, libbfd, avec un outil, addr2line. Cet outil est un bon exemple d'utilisation de la librairie libbfd, et je l'ai simplement repris pour exploiter libbfd. Le résultat est la fonction libtrace_resolve(). Pour plus de détails, référez-vous au fichier README accompagnant le code source joint à cet article.

Puisque les fonctions d'instrumentation sont isolées dans un module indépendant, nous transmettons au module le nom de l'exécutable dont on réalise l'instrumentation via une variable d'environnement (CTRACE_PROGRAM), que nous positionnons avant l'exécution du programme. Ceci est requis afin d'initialiser proprement la recherche des symboles par libbfd.

Note

libbfd est en cours de développement. J'ai utilisé la version 2.18. Elle réalise un travail remarquable bien que l'utilisation de fonctions "inline" affecte sa précision.

Structure de la pile

Pour régler le premier point, nous n'avons pas eu à tenir compte de l'architecture sous-jacente (actuellement libbfd ne dépend pas de l'architecture choisie, mais les choses sont cachées derrière son API). Néanmoins, pour obtenir les arguments et la valeur de retour d'une fonction, nous avons dû examiner la pile, écrire un peu de code spécifique à l'architecture, et exploiter certaines excentricités de gcc. De nouveau, je signale que les compilateurs utilisés sont gcc 4.1 et 4.2 ; les versions précédentes ou ultérieures peuvent fonctionner différemment. Pour résumer :

  • l'architecture x86 fait grandir la pile vers le bas

  • gcc impose la manière d'utiliser la pile—une pile typique est détaillée ci-dessous

  • chaque fonction dispose d'un cadre de pile repéré par les registres ebp (pointeur de base) et esp (pointeur de pile)

  • normalement, il est attendu que le registre eax contienne la valeur de retour

                                \
               +------------+   |
               | arg 2      |   \
               +------------+    >- cadre de pile de la fonction précédente
               | arg 1      |   /
               +------------+   |
               | ret %eip   |   /
               +============+   
               | %ebp sauvé |   \
        %ebp-> +------------+   |
               |            |   |
               | variables  |   \
               |  locales,  |    >- cadre de pile de la fonction courante
               |    etc.    |   /
               |            |   |
               |            |   |
        %esp-> +------------+   /

Dans un monde idéal, le code généré par le compilateur devrait assurer que lors de l'instrumentation à la sortie d'une fonction, la valeur de retour ait été fixée et que les registres CPU aient été mis sur la pile (pour être certain que l'instrumentation d'une fonction ne les affecte pas). Puis la fonction d'instrumentation devrait être appelée, et enfin les registres dépilés. Cet enchaînement permettrait de nous garantir la récupération de la valeur de retour par la fonction d'instrumentation. Le code généré par le compilateur est un peu différent ...

De même, en pratique, de nombreuses options de gcc affectent la structure de pile ou l'utilisation des registres. Les cas les plus évidents sont :

  • -fomit-frame-pointer. Cette option modifie le déplacement à utiliser pour trouver les arguments dans la pile.

  • les options d'optimisation (c'est-à-dire, -Ox). Chacune de ces options inclue d'autres optimisations. La pile n'est pas affectée, et de façon plutôt surprenante, les arguments sont toujours transmis aux fonctions indépendamment du niveau d'optimisation demandé. On aurait pu attendre que certains arguments soient transmis par les registres, auquel cas il aurait été difficile, voire même impossible de récupérer ces arguments. Cependant ces options compliquent la récupération du code de retour. Notez que sur certaines architectures, ces options impliqueront l'optimisation de type -fomit-frame-pointer

Dans tous les cas, soyez prudent : les options de compilation que vous utilisez peuvent vous réserver des surprises cachées.

Suivi des arguments

Lors de mes tests avec les compilateurs, tous les arguments ont été systématiquement transmis par la pile. Donc, c'est trivial, seulement affectés dans une faible mesure par l'option -fomit-frame-pointer, cette option changeant le décalage de début de chaque argument.

Combien d'arguments une fonction possède t-elle ? Combien d'arguments sont passés sur la pile ? Une méthode pour inférer ce nombre d'arguments est de considérer la signature de la fonction (en C++, attention à l'argument caché « this »), c'est la technique utilisée par la fonction __cyg_profile_func_enter().

Dés que nous connaissons le décalage indiquant où les arguments débutent dans la pile, et combien il y en a, il suffit de parcourir la pile pour récupérer les valeurs :

    char *args(char *buf, int len, int nargs, int *frame)
    {
        int i; 
        int offset;

        memset(buf, 0, len); 

        snprintf(buf, len, "("); 
        offset = 1; 
        for (i=0; i<nargs && offset<len; i++) {
            offset += snprintf(buf+offset, len-offset, "%d%s", 
                             *(frame+ARG_OFFET+i), 
                             i==nargs-1 ? " ...)" : ", ");
        }

        return buf; 
    }

Valeurs de retour des fonctions

Il apparaît que la récupération de la valeur de retour est possible seulement lorsque l'on utilise l'option -O0.

Regardons ce qui se passe avec la méthode suivante :

class B {
        ...
        virtual int m1(int i, int j) {printf("B::m1()\n"); f1(i); return 20;}
        ...
    }; 

lorsque l'instrumentation est réalisée avec -O0 :

    080496a2 <_ZN1B2m1Eii>:
     80496a2:    55                       push   %ebp
     80496a3:    89 e5                    mov    %esp,%ebp
     80496a5:    53                       push   %ebx
     80496a6:    83 ec 24                 sub    $0x24,%esp
     80496a9:    8b 45 04                 mov    0x4(%ebp),%eax
     80496ac:    89 44 24 04              mov    %eax,0x4(%esp)
     80496b0:    c7 04 24 a2 96 04 08     movl   $0x80496a2,(%esp)
     80496b7:    e8 b0 f4 ff ff           call   8048b6c <__cyg_profile_func_enter@plt>
     80496bc:    c7 04 24 35 9c 04 08     movl   $0x8049c35,(%esp)
     80496c3:    e8 b4 f4 ff ff           call   8048b7c <puts@plt>
     80496c8:    8b 45 0c                 mov    0xc(%ebp),%eax
     80496cb:    89 04 24                 mov    %eax,(%esp)
     80496ce:    e8 9d f8 ff ff           call   8048f70 <_Z2f1i>
     80496d3:    bb 14 00 00 00           mov    $0x14,%ebx
     80496d8:    8b 45 04                 mov    0x4(%ebp),%eax
     80496db:    89 44 24 04              mov    %eax,0x4(%esp)
     80496df:    c7 04 24 a2 96 04 08     movl   $0x80496a2,(%esp)
     80496e6:    e8 81 f5 ff ff           call   8048c6c <__cyg_profile_func_exit@plt>
     80496eb:    89 5d f8                 mov    %ebx,0xfffffff8(%ebp)
     80496ee:    eb 27                    jmp    8049717 <_ZN1B2m1Eii+0x75>
     80496f0:    89 45 f4                 mov    %eax,0xfffffff4(%ebp)
     80496f3:    8b 5d f4                 mov    0xfffffff4(%ebp),%ebx
     80496f6:    8b 45 04                 mov    0x4(%ebp),%eax
     80496f9:    89 44 24 04              mov    %eax,0x4(%esp)
     80496fd:    c7 04 24 a2 96 04 08     movl   $0x80496a2,(%esp)
     8049704:    e8 63 f5 ff ff           call   8048c6c <__cyg_profile_func_exit@plt>
     8049709:    89 5d f4                 mov    %ebx,0xfffffff4(%ebp)
     804970c:    8b 45 f4                 mov    0xfffffff4(%ebp),%eax
     804970f:    89 04 24                 mov    %eax,(%esp)
     8049712:    e8 15 f5 ff ff           call   8048c2c <_Unwind_Resume@plt>
     8049717:    8b 45 f8                 mov    0xfffffff8(%ebp),%eax
     804971a:    83 c4 24                 add    $0x24,%esp
     804971d:    5b                       pop    %ebx
     804971e:    5d                       pop    %ebp
     804971f:    c3                       ret 

Notez comment le code de retour est placé dans le registre ebx—ce qui est un peu inattendu ici, le registre eax étant utilisé traditionnellement pour les valeurs de retour—et qu'après, la fonction d'instrumentation est appelée. Parfait pour récupérer la valeur de retour, mais pour éviter l'altération du registre ebx dans la fonction d'instrumentation, nous devons le sauver à l'entrée de la fonction et le restaurer à la sortie.

Quand la compilation est effectuée avec d'autres niveaux d'optimisation (-O1...3; ici c'est le cas -O2), le code change :

    080498c0 <_ZN1B2m1Eii>:
     80498c0:    55                       push   %ebp
     80498c1:    89 e5                    mov    %esp,%ebp
     80498c3:    53                       push   %ebx
     80498c4:    83 ec 14                 sub    $0x14,%esp
     80498c7:    8b 45 04                 mov    0x4(%ebp),%eax
     80498ca:    c7 04 24 c0 98 04 08     movl   $0x80498c0,(%esp)
     80498d1:    89 44 24 04              mov    %eax,0x4(%esp)
     80498d5:    e8 12 f4 ff ff           call   8048cec <__cyg_profile_func_enter@plt>
     80498da:    c7 04 24 2d 9c 04 08     movl   $0x8049c2d,(%esp)
     80498e1:    e8 16 f4 ff ff           call   8048cfc <puts@plt>
     80498e6:    8b 45 0c                 mov    0xc(%ebp),%eax
     80498e9:    89 04 24                 mov    %eax,(%esp)
     80498ec:    e8 af f7 ff ff           call   80490a0 <_Z2f1i>
     80498f1:    8b 45 04                 mov    0x4(%ebp),%eax
     80498f4:    c7 04 24 c0 98 04 08     movl   $0x80498c0,(%esp)
     80498fb:    89 44 24 04              mov    %eax,0x4(%esp)
     80498ff:    e8 88 f3 ff ff           call   8048c8c <__cyg_profile_func_exit@plt>
     8049904:    83 c4 14                 add    $0x14,%esp
     8049907:    b8 14 00 00 00           mov    $0x14,%eax
     804990c:    5b                       pop    %ebx
     804990d:    5d                       pop    %ebp
     804990e:    c3                       ret    
     804990f:    89 c3                    mov    %eax,%ebx
     8049911:    8b 45 04                 mov    0x4(%ebp),%eax
     8049914:    c7 04 24 c0 98 04 08     movl   $0x80498c0,(%esp)
     804991b:    89 44 24 04              mov    %eax,0x4(%esp)
     804991f:    e8 68 f3 ff ff           call   8048c8c <__cyg_profile_func_exit@plt>
     8049924:    89 1c 24                 mov    %ebx,(%esp)
     8049927:    e8 f0 f3 ff ff           call   8048d1c <_Unwind_Resume@plt>
     804992c:    90                       nop    
     804992d:    90                       nop    
     804992e:    90                       nop    
     804992f:    90                       nop    

Remarquez que la fonction d'instrumentation est appelée en premier, et que seulement après le registre eax est positionné à la valeur de retour. Par conséquent, si nous voulons absolument récupérer la valeur de retour, nous sommes obligés de compiler avec l'option -O0.

Exemple de sortie

Voici finalement les résultats en sortie. Dans l'interpréteur de commandes, tapez :

$ export CTRACE_PROGRAM=./cpptraced
$ LD_PRELOAD=./libctrace.so ./cpptraced
T0xb7c0f6c0: 0x8048d34 main (0 ...) [from ]
./cpptraced: main(argc=1)
T0xb7c0ebb0: 0x80492d8 thread1(void*) (1 ...) [from ]
T0xb7c0ebb0: 0x80498b2 D (134605416 ...) [from cpptraced.cpp:91]
T0xb7c0ebb0: 0x8049630 B (134605416 ...) [from cpptraced.cpp:66]
B::B()
T0xb7c0ebb0: 0x8049630 B => -1209622540 [from ]
D::D(int=-1210829552)
T0xb7c0ebb0: 0x80498b2 D => -1209622540 [from ]
Hello World! It's me, thread #1!
./cpptraced: done.
T0xb7c0f6c0: 0x8048d34 main => -1212090144 [from ]
T0xb740dbb0: 0x8049000 thread2(void*) (2 ...) [from ]
T0xb740dbb0: 0x80498b2 D (134605432 ...) [from cpptraced.cpp:137]
T0xb740dbb0: 0x8049630 B (134605432 ...) [from cpptraced.cpp:66]
B::B()
T0xb740dbb0: 0x8049630 B => -1209622540 [from ]
D::D(int=-1210829568)
T0xb740dbb0: 0x80498b2 D => -1209622540 [from ]
Hello World! It's me, thread #2!
T#2!
T0xb6c0cbb0: 0x8049166 thread3(void*) (3 ...) [from ]
T0xb6c0cbb0: 0x80498b2 D (134613288 ...) [from cpptraced.cpp:157]
T0xb6c0cbb0: 0x8049630 B (134613288 ...) [from cpptraced.cpp:66]
B::B()
T0xb6c0cbb0: 0x8049630 B => -1209622540 [from ]
D::D(int=0)
T0xb6c0cbb0: 0x80498b2 D => -1209622540 [from ]
Hello World! It's me, thread #3!
T#1!
T0xb7c0ebb0: 0x80490dc wrap_strerror_r (134525680 ...) [from cpptraced.cpp:105]
T0xb7c0ebb0: 0x80490dc wrap_strerror_r => -1210887643 [from ]
T#1+M2 (Success)
T0xb740dbb0: 0x80495a0 D::m1(int, int) (134605432, 3, 4 ...) [from cpptraced.cpp:141]
D::m1()
T0xb740dbb0: 0x8049522 B::m2(int) (134605432, 14 ...) [from cpptraced.cpp:69]
B::m2()
T0xb740dbb0: 0x8048f70 f1 (14 ...) [from cpptraced.cpp:55]
f1 14
T0xb740dbb0: 0x8048ee0 f2(int) (74 ...) [from cpptraced.cpp:44]
f2 74
T0xb740dbb0: 0x8048e5e f3 (144 ...) [from cpptraced.cpp:36]
f3 144
T0xb740dbb0: 0x8048e5e f3 => 80 [from ]
T0xb740dbb0: 0x8048ee0 f2(int) => 70 [from ]
T0xb740dbb0: 0x8048f70 f1 => 60 [from ]
T0xb740dbb0: 0x8049522 B::m2(int) => 21 [from ]
T0xb740dbb0: 0x80495a0 D::m1(int, int) => 30 [from ]
T#2!
T#3!

Notez comment libbfd échoue à résoudre certaines adresses quand la fonction est inline.

Les codes sources de cet article -non traduits NdT- sont disponibles dans une archive unique à cette adresse.

Note

Soyez certain d'utiliser binutils 2.18, ou alors il vous manquera certains fichiers d'entêtes importants (Debian « Etch » ne contient que la version 2.17 actuellement). Vous pouvez tester le code sans installer binutils 2.18, le fichier Makefile accédant au répertoire de construction de binutils (changez le chemin pour pointer sur le répertoire où vous avez décompressé les sources).

Note

Veuillez noter que le code a été conçu pour l'architecture IA32 sur plate-forme Intel™ 32-bits. Nous avons essayé de l'exécuter sur un système x86_64 avec quelques modifications, mais finalement abandonné. Si vous portez les exemples sur plate-forme AMD™ x86_64, merci d'envoyer les patches à l'auteur --René.

Ressources

Aurelian est un programmeur de métier. Il développe parfois sous Windows™, parfois sous Linux, et parfois sur des systèmes embarqués. Il a découvert Linux en 1998 et apprécie de l'utiliser depuis. Il travaille couramment sous Debian.

Adaptation française de la Gazette Linux

L'adaptation française de ce document a été réalisée dans le cadre du Projet de traduction de la Gazette Linux.

Vous pourrez lire d'autres articles traduits et en apprendre plus sur ce projet en visitant notre site : http://wiki.traduc.org/Gazette_Linux.

Si vous souhaitez apporter votre contribution, n'hésitez pas à nous rejoindre, nous serons heureux de vous accueillir.