Vérification de types à l’édition des liens

Avec des collègues, on a discuté des vérifications de types à l’édition des liens. Qu’est ce que l’éditeur de liens (le linker) est capable de vérifier ? Je soutenais qu’il ne vérifiait rien alors qu’un collègue soutenait qu’il vérifiait les prototypes des fonctions. J’ai donc souhaité en savoir un peu plus sur le sujet. On parlait de notre expérience en C, mais je m’intéresse aussi au C++ dans cet article.

En première réponse, on peut dire qu’on avait tous les deux tort et raison. En fait, comme (trop) souvent en C (et sans doute en C++, mais je manque d’expérience avec ce langage), chaque toolchain fait ce que bon lui semble. Beaucoup ne vérifient rien, comme le montreront les exemples suivants, l’édition des liens n’étant que la mise en correspondance de symboles qui sont de simples chaines de caractères ; d’autres peuvent utiliser des techniques plus ou moins évolués pour s’assurer de la cohérence des liens.

Ce n’est pas le compilateur qui vérifie le typage ?

Oui, c’est le rôle du compilateur. Pour chaque unité de compilation, il s’assure que l’utilisation des différents éléments (variable, structures, fonctions, etc.) est bien conforme à leurs déclarations. Si vous déclarer une variable de type pointeur, il émettra un warning si vous essayer de vous en servir comme d’un entier. C’est le cas avec le code suivant :

int f(void)
{
    void* p = &main; 
    return p;
}

gcc émet le warning suivant :

main.c: In function ‘f’:
 main.c:24:2: warning: return makes integer from pointer without a cast [enabled by default]
 return p:
 ^
 main.c:24:10: error: expected ‘;’ before ‘:’ token
 return p:
 ^

Si le compilateur fait le boulot, pourquoi se préoccuper du linker ?

Comme précisé ci-dessus, le compilateur fait ce travail pour chaque unité de compilation, mais il ne fait aucun vérification entre unités. C’est le travail du linker que d’assembler les différentes unités compilées. Si une unité de compilation (aka un fichier C) fait référence à une fonction qui est dans une autre unité grâce à une déclaration avec le mot-clé extern, qui vérifie que cette déclarée est conforme à la définition de la fonction ? Le linker est le dernier rempart pour ne pas associer deux symboles qui ne sont pas utilisés de la même manière dans deux unités de compilation.

Pour tester cela, j’ai écrit deux fichiers C. Le premier, code.c contient des variables globales (c’est mal, c’est juste pour l’exemple) et des fonctions :

const float PI = 3.14;
const int magic = 42;

int function_char(signed char n)
{
    return n * 2;
}

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

Le second, main.c les utilise :

#include <stdio.h>

extern int function_char(unsigned char a);
extern double function_doubles(float a, float b);
extern int PI(void);
extern int magic(void);

int main(void)
{
	printf("function_char(200) = %d\n", function_char(200));
	
	printf("function_doubles(3.14 + 2.42) = %f\n", function_doubles(3.14, 2.42));
	
	printf("PI as function: %d\n", PI());
	
	printf("magic as function: %d\n", magic());
}

Toutes les déclarations externes sont fausses. Les fonctions n’ont pas les bons prototypes et, beaucoup plus grave, les variables sont ici utilisées comme des fonctions. Ça ne va quand même pas passer !? Désolé de vous décevoir mais gcc vous génère un exécutable sans ciller :

$ gcc -Wall -Wextra -std=c99 main.c code.c && ./a.out 
function_char(200) = -112
function_doubles(3.14 + 2.42) = 6.720002
PI as function: 41
Segmentation fault (core dumped)

Il n’y a donc aucune vérification lors de l’édition des liens…

  • 200, qui tient normalement très bien dans un unsigned char, est finalement interprété comme un signed char, ce qui donne un nombre négatif. On obtient donc -112 au lieu de 400.
  • On pourrait penser que les floats passeraient très bien en devenant des doubles, raté.
  • Exécuter PI donne un résultat ! On se demande un peu ce qu’il est allé exécuter…
  • Exécuter magic provoque une segmentation fault. Je dirai « heureusement ! » car ça nous permet de se rendre compte d’un problème.

Comment se protéger de tels problèmes ?

Faut-il rendre les éditeurs de liens plus sécurisés ? Possible, mais ce n’est pas dans notre périmètre d’actions en tant que développeurs. La meilleure parade est de ne jamais utiliser de déclarations externes dans un fichier source. Celles-ci devraient toujours être dans un fichier d’en-tête, qui serait inclus dans le fichier source où l’on souhaite utiliser les éléments ainsi que dans le fichier source où ils sont définis. Ainsi, le compilateur pourra vérifier que la déclaration donnée dans le fichier d’en-tête est conforme avec l’utilisation et avec la définition.

En reprenant mon exemple précédent, j’ai écrit le fichier d’en-tête suivant code.h :

extern const float PI;
extern const int magic;
int function_char(signed char n);
double function_doubles(double a, double b);

Je l’ai inclus dans main.c (en supprimant les déclarations externes) et dans code.c et j’ai récompilé :

$ gcc -Wall -Wextra -std=c99 main.c code.c && ./a.out 
main.c: In function ‘main’:
main.c:10:35: error: called object ‘PI’ is not a function or function pointer
  printf("PI as function: %d\n", PI());
                                   ^
In file included from main.c:2:0:
code.h:1:20: note: declared here
 extern const float PI;
                    ^
main.c:12:41: error: called object ‘magic’ is not a function or function pointer
  printf("magic as function: %d\n", magic());
                                         ^
In file included from main.c:2:0:
code.h:2:18: note: declared here
 extern const int magic;
                  ^

En supprimant les appels à PI et magic, cela compile sans warning et donne le résultat suivant :

function_char(200) = -112
function_doubles(3.14 + 2.42) = 5.560000

Le calcul avec des flottants fonctionne correctement. On a toujours notre overflow pour 200 et le compilateur ne nous dit rien car il y a probablement une règle de conversion définie. C’est quand même beaucoup mieux !

Notez au passage que cela n’est possible que si vous recompiler tout. Si au lieu de code.c, vous avez code.o ou code.a accompagné de code.h, il ne vous reste plus qu’à souhaiter que le fichier d’en-tête est cohérent avec le contenu de la bibliothèque. Cela m’est déjà arrivé avec des définitions de structures par exemple.

Un pas de plus dans les profondeurs de l’édition des liens

Pour comprendre un peu mieux pourquoi l’éditeur de liens ne peut pas vérifier les types, il faut regarder un peu plus en détails les résultats de la compilation, les fichiers objets *.o. Ici, je suis revenu au code de départ, avant l’utilisation de code.h, et j’ai modifié un peu ma ligne de commande pour obtenir les fichiers objets :

$ gcc -Wall -Wextra -std=c99 main.c code.c -c

Je peux maintenant regarder la table des symboles de deux fichiers objets :

$ objdump -t code.o main.o 

code.o:     file format elf32-i386

SYMBOL TABLE:
00000000 l    df *ABS*    00000000 code.c
00000000 l    d  .text    00000000 .text
00000000 l    d  .data    00000000 .data
00000000 l    d  .bss    00000000 .bss
00000000 l    d  .rodata    00000000 .rodata
00000000 l    d  .note.GNU-stack    00000000 .note.GNU-stack
00000000 l    d  .eh_frame    00000000 .eh_frame
00000000 l    d  .comment    00000000 .comment
00000000 g     O .rodata    00000004 PI
00000004 g     O .rodata    00000004 magic
00000000 g     F .text    00000014 function_char
00000014 g     F .text    0000002e function_doubles

main.o:     file format elf32-i386

SYMBOL TABLE:
00000000 l    df *ABS*    00000000 main.c
00000000 l    d  .text    00000000 .text
00000000 l    d  .data    00000000 .data
00000000 l    d  .bss    00000000 .bss
00000000 l    d  .rodata    00000000 .rodata
00000000 l    d  .note.GNU-stack    00000000 .note.GNU-stack
00000000 l    d  .eh_frame    00000000 .eh_frame
00000000 l    d  .comment    00000000 .comment
00000000 g     F .text    0000007c main
00000000         *UND*    00000000 function_char
00000000         *UND*    00000000 printf
00000000         *UND*    00000000 function_doubles
00000000         *UND*    00000000 PI
00000000         *UND*    00000000 magic

Dans code.o, on voit bien nos variables et nos constantes. Il y a leurs noms et on peut savoir si ce sont des variables ou des fonctions car les premières sont dans la section .rodata (pour Read-Only Data, car ce sont des constants ; elles auraient été dans .data sans le qualificatif const) et les secondes dans la section .text. Dans main.o, ces symboles sont marqués comme *UND* et n’ont pas d’adresse. C’est le principe même de l’édition des liens que de leur donner une adresse en cherchant le symbole de même nom dans les autres fichiers objets. On constate bien que le compilateur ne laisse aucune information dans main.o pour permettre au linker pour vérifier les types.

Que se passe t-il en C++ ?

C’est presque radicalement différent !

$ g++ -Wall -Wextra -std=c++11 main.c code.c && ./a.out 
/tmp/cc7UfAzd.o: In function `main':
main.c:(.text+0x11): undefined reference to `function_char(unsigned char)'
main.c:(.text+0x37): undefined reference to `function_doubles(float, float)'
main.c:(.text+0x4c): undefined reference to `PI()'
main.c:(.text+0x61): undefined reference to `magic()'
collect2: error: ld returned 1 exit status

C’est génial ! Le linker vérifie les types ! Euh… non. Regardons les tables des symboles :

$ g++ -Wall -Wextra -std=c++11 main.c code.c -c
$ objdump -t code.o main.o 
code.o:     file format elf32-i386

SYMBOL TABLE:
00000000 l    df *ABS*    00000000 code.c
00000000 l    d  .text    00000000 .text
00000000 l    d  .data    00000000 .data
00000000 l    d  .bss    00000000 .bss
00000000 l    d  .rodata    00000000 .rodata
00000000 l    d  .note.GNU-stack    00000000 .note.GNU-stack
00000000 l    d  .eh_frame    00000000 .eh_frame
00000000 l    d  .comment    00000000 .comment
00000000 g     O .rodata    00000004 PI
00000004 g     O .rodata    00000004 magic
00000000 g     F .text    00000014 _Z13function_chara
00000014 g     F .text    00000026 _Z16function_doublesdd

main.o:     file format elf32-i386

SYMBOL TABLE:
00000000 l    df *ABS*    00000000 main.c
00000000 l    d  .text    00000000 .text
00000000 l    d  .data    00000000 .data
00000000 l    d  .bss    00000000 .bss
00000000 l    d  .rodata    00000000 .rodata
00000000 l    d  .note.GNU-stack    00000000 .note.GNU-stack
00000000 l    d  .eh_frame    00000000 .eh_frame
00000000 l    d  .comment    00000000 .comment
00000000 g     F .text    0000007c main
00000000         *UND*    00000000 _Z13function_charh
00000000         *UND*    00000000 printf
00000000         *UND*    00000000 _Z16function_doublesff
00000000         *UND*    00000000 _Z2PIv
00000000         *UND*    00000000 _Z5magicv

WTF?! Que sont ces noms ésotériques ?

C++ autorise la surcharge de fonctions, c’est-à-dire que deux fonctions peuvent avoir le même nom tout en ayant des prototypes différents. Comment faire alors pour différencier void f(int) de void f(int, int) ou encore de void f(void) ? Le compilateur utilise la technique du name mangling : il créé le symbole à partir du nom de la fonction tout en rajoutant des caractères plus ou moins cryptiques pour distinguer les prototypes. C’est ce que nous montre les tables ci-dessus. Les symboles sont différents entre les deux fichiers objets car les déclarations sont différentes entre les deux fichiers sources. Lors de l’édition des liens, il n’y a pas plus de vérification de types qu’en C (c’est d’ailleurs toujours ld qui fait ce boulot), mais comme les noms de symboles diffèrent selon le typage, il n’est pas possible de lier function_char utilisée dans main.c avec celle définie dans code.c car elles conduisent à des symboles différents.

Au passage, il n’est pas légal de surcharger une fonction en changeant son type de retour ; le name mangling ne « protège » donc que des incohérences de paramètres.

Le fameux extern "C" sert à préciser au compilateur de pas utiliser de name mangling pour générer le symbole correspondant à une fonction. Cela sert quand on utilise cette fonction dans fichier compilé en C++ alors que sa définition est dans un fichier compilé en C.

Conclusion et quelques liens

En conclusion, on voit qu’il faut se méfier des incohérences de typage entre plusieurs unité de compilation. C’est valable pour les fonctions, les variables, les constants ou encore les définitions de nouveaux types comme les structures. De manière accidentelle, le C++ est moins sensible que le C à cause de la surcharge de fonctions et de la technique du name mangling. Les normes C et C++ ne définissant rien à ce sujet, chaque toolchain pourra implémenter diverses techniques pour éviter des erreurs de type à l’édition des liens. On pourrait imaginer du name mangling en C même sans surcharge. De manière générale, la meilleure solution est celle déjà évoquée de bannir le mot-clé extern des fichiers *.c et de l’utiliser uniquement dans les fichiers *.h, mais de toute façon, vous utilisez déjà toujours des fichiers d’en-tête pour accéder aux API des autres modules, non ?

Some Differences Between C and C++ par Steve Jacobson
Voir les deux dernières sections Name Mangling et Linking C and C++ files (or Libraries)

Beginner’s Guide to Linkers

Is there any type checking in C or C++ linkers? sur stackoverflow

Checking C Declarations at Link Time par Diomidis Spinellis

Publicités

4 Réponses

  1. Hello, ça faisait longtemps 🙂

    Quelques compléments vu que je pratique pas mal le C et aussi le C++…

    Le problème en C, c’est qu’on a le droit de faire n’importe quoi. On prend la norme C99 (enfin, le draft public) et on regarde la section 6.2.2.2: si deux identifiers ont le même nom, alors ils désignent le même objet (fonction ou variable). Et tant pis si les prototypes ne correspondent pas, en C standard, ça doit compiler! Et il y a un seul namespace pour les fonctions et les variables (qui est défini en 6.2.3.1).

    Pour éviter complètement les conflits, il faudrait:
    – Abuser du static dès qu’on peut,
    – Préfixer les fonctions et variables avec le nom du module qui les définit, et avec des identifiants de type (bref, faire du mangling à la main)

    Comme si ça ne suffisait pas, pour les externs, la norme C ne garantit pas que la comparaison de nom aille plus loins que les 31 premiers caractères (5.2.3.1). Ce qui permet d’utiliser des formats de fichiers objets bien pourris et de faire du C quand même. Du coup, MonModuleAvecUnNomSuperLong_FonctionQuiFaitUnTruc et MonModuleAvecUnNomSuperLong_FonctionQuiFaitUnAutreTrucQuiARienAVoir peuvent se retrouver à désigner la même fonction. Génial, merci le C!

    On pourrait être sauvés par GCC, mais il botte en touche (https://gcc.gnu.org/onlinedocs/gcc-6.2.0/gcc/Identifiers-implementation.html#Identifiers-implementation) en laissant au linker le soin de décider de la longueur limite. En disant qu’il n’y a « presque jamais » de limite. Pour tous les autres compilateurs, méfiance!

    Du coup, on a le C++qui corrige pas mal de ces problèmes. Comme tu l’as dit, il fait tout seul le mangling en fonction des paramètres et distingue les fonctions des variables. Il permet aussi de faire des namespaces, qui permettent simplement de préfixer automatiquement le nom des variables et des fonctions:

    namespace MonModule {
    Fonction(int a);
    Fonction(float a);
    };

    On peut ensuite utiliser MonModule::Fonction(unEntier) ou bien faire un « using namespace MonModule; » pour y accéder sans préfixe. Dans ce deuxième cas, le compilateur prévient si un appel de fonction matche des choses dans deux namespaces.

    La spec du C++ dit qu’en théorie il n’y a pas de limite au nombre de caractères significatif, mais qu’en pratique, rien n’est infini. Du coup, GCC n’a pas de limite, et Visual C++ a une limite de 2047 caractères. On est tranquilles avec ça.

    Pour finir, l’astuce du jour: l’outil c++filt permet de décoder le mangling de gcc. On peut piper la sortie de readelf ou objdump dedans, pour avoir des noms « lisibles » dans la sortie (je met entre guillemets parce que quand c’est tout plein de templates, de namespaces et d’autres trucs chelous, c’est finalement pas tellement plus lisible).

    J'aime

    27 septembre 2016 à 9:29

    • Ton commentaire me fait peur…. Surtout le coup du 31 ! C’est encore pire que prévu !

      J'aime

      5 octobre 2016 à 9:51

      • En « vrai », je n’ai jamais vu de compilateur ou de linker qui se limite à 31 caractères (ce n’est que le minimum pour le C standard, les implémentations ont le droit de faire plus).
        Mais ça montre à quel point il peut être compliqué de faire du C vraiement portable dans *tous* les cas. C’est à ranger à côté des chars qui ne font pas 8 bit, des implémentations ou les nombres signés ne sont pas en complément à 2, et des architecture à mémoire segmentée où un ptrdiff_t n’a pas forcément la même taille qu’un size_t.
        Ce sont tous ces petits détails qui font qu’on peut coder en C sur n’importe quelle architecture sans que ça coûte trop cher à implémenter. Et ce sont ces mêmes détails qui font qu’il ne suffit pas de bien connaître la norme C ISO, il faut aussi jeter un oeuil aux infos du compilateur utilisé (quand elles sont fournies…)

        J'aime

        5 octobre 2016 à 9:59

  2. Je me doute bien qu’en vrai, les linkers ont du prendre une marge de sécurité 😉 Mais cela veut une fois de plus dire que tu peux avoir des surprises. Peut-être que cette surprise n’arrivera jamais. Peut-être qu’elle arrivera dans très longtemps.

    C’est vrai que c’est ce qui rend le C « magique » : il est très facile de porter le langage un peu partout. C’est aussi ce qui en fait son plus gros problème : un code vraiment portable n’est pas facile à écrire. On ne peut pas toujours tout avoir dans la vie 🙂

    J'aime

    10 octobre 2016 à 7:25

Laisser un commentaire

Entrez vos coordonnées ci-dessous ou cliquez sur une icône pour vous connecter:

Logo WordPress.com

Vous commentez à l'aide de votre compte WordPress.com. Déconnexion / Changer )

Image Twitter

Vous commentez à l'aide de votre compte Twitter. Déconnexion / Changer )

Photo Facebook

Vous commentez à l'aide de votre compte Facebook. Déconnexion / Changer )

Photo Google+

Vous commentez à l'aide de votre compte Google+. Déconnexion / Changer )

Connexion à %s