La plupart des compilateurs C vous permettent d'accéder à un tableau
extern
avec des limites non définies, par exemple:
extern int external_array[]; int array_get (long int index) { return external_array[index]; }
La définition de external_array peut être dans une autre unité de traduction et peut ressembler à ceci:
int external_array[3] = { 1, 2, 3 };
La question est de savoir ce qui se passe si cette définition séparée change comme ceci:
int external_array[4] = { 1, 2, 3, 4 };
Ou alors:
int external_array[2] = { 1, 2 };
L'interface binaire sera-t-elle préservée (à condition qu'il existe un mécanisme permettant à l'application de déterminer la taille du tableau au moment de l'exécution)?
Curieusement, sur de nombreuses architectures, l'
augmentation de la taille du tableau viole la compatibilité d'interface binaire (ABI). La réduction de la taille de la baie peut également entraîner des problèmes de compatibilité. Dans cet article, nous allons examiner de plus près la compatibilité ABI et expliquer comment éviter les problèmes.
Liens dans la section des données du fichier exécutable
Pour comprendre comment la taille du tableau devient une partie de l'interface binaire, nous devons d'abord examiner les liens dans la section des données du fichier exécutable. Bien sûr, les détails dépendent de l'architecture spécifique, et ici nous nous concentrerons sur l'architecture x86-64.
L'architecture x86-64 prend en charge l'adressage par rapport au compteur de programme, c'est-à-dire que l'accès à la variable de tableau global, comme dans la fonction
array_get
présentée précédemment, peut être compilé en une seule instruction
movl
:
array_get: movl external_array(,%rdi,4), %eax ret
À partir de cela, l'assembleur crée un fichier objet dans lequel l'instruction est marquée comme
R_X86_64_32S
.
0000000000000000 : 0: mov 0x0(,%rdi,4),%eax 3: R_X86_64_32S external_array 7: retq
Ce déplacement indique à l'éditeur de liens (
ld
) comment remplir l'emplacement correspondant de la variable
external_array
pendant la liaison lors de la création de l'exécutable.
Cela a deux conséquences importantes.
- Étant donné que le décalage de la variable est déterminé au moment de la génération, au moment de l'exécution, il n'y a pas de surcharge pour le déterminer. Le seul prix est l'accès à la mémoire elle-même.
- Pour déterminer le décalage, vous devez connaître la taille de toutes les données variables. Sinon, il serait impossible de calculer le format de la section de données lors de la mise en page.
Pour les implémentations C orientées vers l'
exécutable et le format de lien (ELF) , comme dans GNU / Linux, les références aux variables
extern
ne contiennent pas de tailles d'objet. Dans l'exemple
array_get
taille de l'objet est inconnue même du compilateur. En fait, l'ensemble du fichier assembleur ressemble à ceci (en omettant uniquement les informations de promotion de
-fno-asynchronous-unwind-tables
, ce qui est techniquement requis pour la conformité psABI):
.file "get.c" .text .p2align 4,,15 .globl array_get .type array_get, @function array_get: movl external_array(,%rdi,4), %eax ret .size array_get, .-array_get .ident "GCC: (GNU) 8.3.1 20190223 (Red Hat 8.3.1-2)" .section .note.GNU-stack,"",@progbits
Il n'y a aucune information de taille pour
external_array
dans ce fichier assembleur: la seule référence de caractère est sur la ligne avec l'instruction
movl
, et les seules données numériques dans l'instruction sont la taille de l'élément de tableau (implicite par
movl
multiplié par 4).
Si ELF nécessite des tailles pour les variables non définies, il sera même impossible de compiler la fonction
array_get
.
Comment l'éditeur de liens obtient-il la taille réelle des caractères? Il regarde la définition du symbole et utilise les informations de taille qu'il y trouve. Cela permet au compilateur de calculer la disposition de la section de données et de remplir les mouvements de données avec les décalages appropriés.
Objets ELF courants
Les implémentations C pour ELF ne nécessitent pas que le programmeur ajoute du balisage au code source pour indiquer si la fonction ou la variable se trouve dans l'objet actuel (qui peut être la bibliothèque ou le fichier exécutable principal) ou dans un autre objet. L'éditeur de liens et le chargeur dynamique s'en occuperont.
Dans le même temps, on souhaitait que les fichiers exécutables ne réduisent pas les performances en modifiant le modèle de compilation. Cela signifie que lors de la compilation du code source du programme principal (c'est-à-dire sans
-fPIC
, et dans ce cas particulier sans
-fPIE
), la fonction
array_get
compilée
exactement dans
la même séquence de commandes avant d'introduire des objets partagés dynamiques. De plus, peu importe si la variable
external_array
est définie dans le fichier exécutable le plus basique ou si un objet partagé est chargé séparément au moment de l'exécution. Les instructions créées par le compilateur sont les mêmes dans les deux cas.
Comment est-ce possible? Après tout, les objets ELF courants sont indépendants de la position. Ils sont chargés à
des adresses aléatoires et imprévisibles lors de l'exécution. Cependant, le compilateur génère une séquence de code machine qui nécessite que ces variables soient situées à un
décalage fixe calculé pendant la liaison , bien avant le démarrage du programme.
Le fait est qu'un seul objet chargé (le fichier exécutable principal) utilise ces décalages fixes. Tous les autres objets (le chargeur dynamique lui-même, la bibliothèque d'exécution C et toute autre bibliothèque utilisée par le programme) sont compilés et compilés en tant qu'objets complètement indépendants de la position (PIC). Pour ces objets, le compilateur charge l'adresse réelle de chaque variable à partir de la table de décalage globale (GOT). Nous pouvons voir ce rond-point si nous
array_get
exemple
-fPIC
avec
-fPIC
, ce qui conduit à un tel code d'assembly:
array_get: movq external_array@GOTPCREL(%rip), %rax movl (%rax,%rdi,4), %eax ret
Par conséquent, l'adresse de la variable
external_array
n'est plus codée en dur et peut être modifiée au moment de l'exécution en initialisant correctement l'enregistrement GOT. Cela signifie qu'au moment de l'exécution, la définition de
external_array
peut être dans le même objet partagé, un autre objet partagé ou le programme principal. Le chargeur dynamique trouvera la définition appropriée sur la base des règles de recherche de caractères ELF et associera la référence de symbole non définie à sa définition en mettant à jour l'enregistrement GOT à son adresse réelle.
Nous revenons à l'exemple d'origine, où la fonction
array_get
est dans le programme principal, donc l'adresse de la variable est spécifiée directement. L'idée clé implémentée dans l'éditeur de liens est que le programme principal fournira une définition de variable
external_array
,
même si elle est réellement définie dans un objet commun au moment de l'exécution . Au lieu de spécifier la définition initiale de la variable dans l'objet partagé, le chargeur dynamique sélectionnera une
copie de la variable dans la section des données du fichier exécutable.
Cela a deux conséquences importantes. Tout d'abord, rappelez-vous que
external_array
est défini comme suit:
int external_array[3] = { 1, 2, 3 };
Il y a ici un initialiseur qui doit être appliqué à la définition dans le fichier exécutable principal. Pour ce faire, dans le fichier exécutable principal, un lien vers l'emplacement de
copie de copie du symbole est placé. La
readelf -rW
affiche en déplaçant
R_X86_64_COPY
.
La section de réinstallation '.rela.dyn' à l'offset 0x408 contient 3 entrées:
Type d'informations de décalage Valeur du symbole Nom du symbole + ajout
0000000000403ff0 0000000100000006 R_X86_64_GLOB_DAT 0000000000000000 __libc_start_main@GLIBC_2.2.5 + 0
0000000000403ff8 0000000200000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
0000000000404020 0000000300000005 R_X86_64_COPY 0000000000404020 tableau_externe + 0
Comme les autres mouvements, le mouvement de copie est géré par le chargeur dynamique. Il comprend une opération de copie simple au niveau du bit. La cible de la copie est déterminée par le décalage de déplacement (
0000000000404020
dans l'exemple). La source est déterminée lors de l'exécution en fonction du nom du symbole (
external_array
) et de sa valeur. Lors de la création d'une copie, le chargeur dynamique examinera également la taille du caractère pour obtenir le nombre d'octets à copier. Pour rendre tout cela possible, le symbole
external_array
est automatiquement exporté du fichier exécutable en tant que symbole spécifique afin qu'il soit visible par le chargeur dynamique au moment de l'exécution. La table des symboles dynamiques (
.dynsym
) reflète cela, comme le montre la commande
readelf -sW
:
La table de symboles '.dynsym' contient 4 entrées:
Num: Valeur Taille Type Lier Vis Ndx Nom
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_main@GLIBC_2.2.5 (2)
2: 0000000000000000 0 NOTYPE FAIBLE DEFAUT UND __gmon_start__
3: 0000000000404020 12 OBJET GLOBAL DEFAULT 22 tableau_externe
D'où proviennent les informations sur la taille de l'objet (12 octets, dans cet exemple)? L'éditeur de liens ouvre tous les objets courants, recherche sa définition et prend des informations sur la taille. Comme précédemment, cela permet à l'éditeur de liens de calculer la disposition de la section de données afin que des décalages fixes puissent être utilisés. Encore une fois, la taille de la définition dans l'exécutable principal est fixe et ne peut pas être modifiée au moment de l'exécution.
L'éditeur de liens dynamique redirige également les liens symboliques des objets partagés vers la copie déplacée dans l'exécutable principal. Cela garantit que dans l'ensemble du programme, il n'y a qu'une seule copie de la variable, comme l'exige la sémantique du langage C. Sinon, si la variable change après l'initialisation, les mises à jour à partir du fichier exécutable principal ne seront pas visibles pour les objets partagés dynamiques et vice versa.
Impact sur la compatibilité binaire
Que se passe-t-il si nous modifions la définition de
external_array
dans un objet partagé sans lier (ou recompiler) le programme principal? Tout d'abord, envisagez d'
ajouter un élément de tableau.
int external_array[4] = { 1, 2, 3, 4 };
Cela générera un avertissement du chargeur dynamique lors de l'exécution:
main-program: Symbol `external_array' has different size in shared object, consider re-linking
Le programme principal contient toujours une définition
external_array
avec un espace pour seulement 12 octets. Cela signifie que la copie est incomplète: seuls les trois premiers éléments du tableau sont copiés. Par conséquent, l'accès à l'élément de tableau
extern_array[3]
pas défini. Cette approche affecte non seulement le programme principal, mais également tout le code du processus, car toutes les références à
extern_array
ont été redirigées vers la définition du programme principal. Cela inclut un objet générique qui fournit une définition
extern_array
. Il n'est probablement pas prêt à faire face à une situation où un élément de tableau dans sa propre définition a disparu.
Que diriez-vous de changer dans la direction opposée, de supprimer un élément?
int external_array[2] = { 1, 2 };
Si le programme évite d'accéder à l'élément de tableau
extern_array[2]
, car il détecte en quelque sorte la longueur réduite du tableau, alors cela fonctionnera. Après le tableau, il y a de la mémoire inutilisée, mais cela ne cassera pas le programme.
Cela signifie que nous obtenons la règle suivante:
- L'ajout d'éléments à une variable de tableau global viole la compatibilité binaire.
- La suppression d'éléments peut rompre la compatibilité s'il n'existe aucun mécanisme qui empêche l'accès aux éléments supprimés.
Malheureusement, l'avertissement du chargeur dynamique semble plus inoffensif qu'il ne l'est en réalité, et pour les éléments distants, il n'y a aucun avertissement.
Comment éviter cette situation
La détection des modifications ABI est assez facile avec des outils comme
libabigail .
Le moyen le plus simple d'éviter cette situation consiste à implémenter une fonction qui renvoie l'adresse du tableau:
static int local_array[3] = { 1, 2, 3 }; int * get_external_array (void) { return local_array; }
Si la définition du tableau ne peut pas être rendue statique en raison de la façon dont il est utilisé dans la bibliothèque, nous pouvons plutôt masquer sa visibilité et également empêcher son exportation et, par conséquent, éviter le problème de troncature:
int local_array[3] __attribute__ ((visibility ("hidden"))) = { 1, 2, 3 };
Tout est beaucoup plus compliqué si la variable de tableau est exportée pour des raisons de compatibilité descendante. Étant donné que le tableau de la bibliothèque est tronqué, l'ancien programme principal avec une définition de tableau plus courte ne pourra pas fournir l'accès au tableau complet pour le nouveau code client s'il est utilisé avec le même tableau global. Au lieu de cela, la fonction d'accès peut utiliser un tableau séparé (statique ou masqué), ou peut-être un tableau séparé pour les éléments ajoutés à la fin. L'inconvénient est qu'il n'est pas possible de tout stocker dans un tableau continu si la variable de tableau est exportée pour une compatibilité descendante. La conception de l'interface secondaire devrait refléter cela.
En utilisant le contrôle de version des caractères, vous pouvez exporter plusieurs versions avec des tailles différentes, sans jamais changer la taille dans une version particulière. En utilisant ce modèle, les nouveaux programmes associés utiliseront toujours la dernière version, probablement avec la plus grande taille. Étant donné que la version et la taille du symbole sont fixées en même temps par l'éditeur de liens, elles sont toujours cohérentes. La bibliothèque GNU C utilise cette approche pour les variables historiques
sys_errlist
et
sys_siglist
. Cependant, cela ne fournit toujours pas un seul tableau continu.
Tout bien considéré, une fonction d'accesseur (par exemple, la fonction
get_external_array
ci-dessus) est la meilleure approche pour éviter ce problème de compatibilité ABI.