Problèmes courants de migration ARM Visual C++
Lorsque vous utilisez le compilateur Microsoft C++ (MSVC), le même code source C++ peut produire des résultats différents sur l’architecture ARM que sur les architectures x86 ou x64.
Sources de problèmes de migration
De nombreux problèmes que vous pouvez rencontrer lorsque vous migrez du code à partir des architectures x86 ou x64 vers l’architecture ARM sont liés aux constructions de code source qui peuvent appeler un comportement non défini, défini par l’implémentation ou non spécifié.
Le comportement non défini est le comportement que la norme C++ ne définit pas et qui est dû à une opération qui n’a aucun résultat raisonnable : par exemple, en convertissant une valeur à virgule flottante en entier non signé, ou en déplaçant une valeur par un certain nombre de positions négatives ou dépassant le nombre de bits dans son type promu.
Le comportement défini par l’implémentation est le comportement que la norme C++ exige que le fournisseur du compilateur définisse et documente. Un programme peut s’appuyer en toute sécurité sur le comportement défini par l’implémentation, même si cela peut ne pas être portable. Parmi les exemples de comportement défini par l’implémentation, citons les tailles des types de données intégrés et leurs exigences d’alignement. Un exemple d’opération susceptible d’être affectée par le comportement défini par l’implémentation accède à la liste des arguments de variable.
Le comportement non spécifié est le comportement que la norme C++ laisse intentionnellement non déterministe. Bien que le comportement soit considéré comme non déterministe, des appels particuliers de comportement non spécifié sont déterminés par l’implémentation du compilateur. Toutefois, il n’est pas nécessaire qu’un fournisseur du compilateur prédéfinit le résultat ou garantisse un comportement cohérent entre les appels comparables et il n’y a aucune exigence pour la documentation. Un exemple de comportement non spécifié est l’ordre dans lequel les sous-expressions, qui incluent des arguments à un appel de fonction, sont évaluées.
D’autres problèmes de migration peuvent être attribués aux différences matérielles entre les architectures ARM et x86 ou x64 qui interagissent différemment avec la norme C++. Par exemple, le modèle de mémoire forte de l’architecture x86 et x64 fournit volatile
des variables qualifiées qui ont été utilisées pour faciliter certains types de communication entre threads dans le passé. Toutefois, le modèle de mémoire faible de l’architecture ARM ne prend pas en charge cette utilisation, ni la norme C++ ne l’exige pas.
Important
Bien que volatile
certaines propriétés puissent être utilisées pour implémenter des formes limitées de communication entre threads sur x86 et x64, ces propriétés supplémentaires ne sont pas suffisantes pour implémenter la communication entre threads en général. La norme C++ recommande que cette communication soit implémentée à l’aide de primitives de synchronisation appropriées à la place.
Étant donné que différentes plateformes peuvent exprimer ces types de comportement différemment, le portage de logiciels entre plateformes peut être difficile et sujette aux bogues s’il dépend du comportement d’une plateforme spécifique. Bien que la plupart de ces types de comportement puissent être observés et peuvent apparaître stables, la confiance en eux est au moins non portable, et dans les cas de comportement non défini ou non spécifié, est également une erreur. Même le comportement cité dans ce document ne doit pas être utilisé et peut changer dans les futures implémentations du compilateur ou du processeur.
Exemples de problèmes de migration
Le reste de ce document décrit comment le comportement différent de ces éléments de langage C++ peut produire des résultats différents sur différentes plateformes.
Conversion de virgule flottante en entier non signé
Dans l’architecture ARM, la conversion d’une valeur à virgule flottante en entier 32 bits sature vers la valeur la plus proche que l’entier peut représenter si la valeur à virgule flottante est en dehors de la plage que l’entier peut représenter. Sur les architectures x86 et x64, la conversion s’encapsule si l’entier n’est pas signé ou est défini sur -2147483648 si l’entier est signé. Aucune de ces architectures ne prend directement en charge la conversion de valeurs à virgule flottante en types entiers plus petits ; Au lieu de cela, les conversions sont effectuées en 32 bits, et les résultats sont tronqués à une taille plus petite.
Pour l’architecture ARM, la combinaison de saturation et de troncation signifie que la conversion en types non signés saturés correctement les types non signés plus petits lorsqu’il saturé un entier 32 bits, mais produit un résultat tronqué pour les valeurs supérieures au type plus petit peut représenter, mais trop petit pour saturer l’entier 32 bits complet. La conversion sature également correctement pour les entiers signés 32 bits, mais la troncation des entiers saturés, les entiers signés entraînent -1 pour les valeurs saturées positivement et 0 pour les valeurs saturées négativement. La conversion en entier signé plus petit produit un résultat tronqué imprévisible.
Pour les architectures x86 et x64, la combinaison du comportement de wrap-around pour les conversions d’entiers non signés et l’évaluation explicite pour les conversions d’entiers signés sur le dépassement, ainsi que la troncation, rendent les résultats pour la plupart des décalages imprévisibles s’ils sont trop volumineux.
Ces plateformes diffèrent également de la façon dont elles gèrent la conversion de NaN (Not-a-Number) en types entiers. Sur ARM, NaN se convertit en 0x00000000 ; sur x86 et x64, il se convertit en 0x80000000.
La conversion à virgule flottante ne peut être effectuée que si vous savez que la valeur se trouve dans la plage du type entier vers lequel elle est convertie.
Comportement de l’opérateur Shift (<<>>)
Dans l’architecture ARM, une valeur peut être décalée vers la gauche ou la droite jusqu’à 255 bits avant que le modèle commence à se répéter. Sur les architectures x86 et x64, le modèle est répété à chaque multiple de 32, sauf si la source du modèle est une variable 64 bits ; dans ce cas, le modèle se répète à chaque multiple de 64 sur x64, et tous les multiples de 256 sur x86, où une implémentation logicielle est utilisée. Par exemple, pour une variable 32 bits qui a une valeur de 1 décalée à gauche par 32 positions, sur ARM, le résultat est 0, sur x86, le résultat est 1 et sur x64, le résultat est également 1. Toutefois, si la source de la valeur est une variable 64 bits, le résultat sur les trois plateformes est 4294967296 et la valeur n’est pas « encapsulée » tant qu’elle n’a pas décalé 64 positions sur x64 ou 256 positions sur ARM et x86.
Étant donné que le résultat d’une opération de décalage qui dépasse le nombre de bits dans le type source n’est pas défini, le compilateur n’est pas tenu d’avoir un comportement cohérent dans toutes les situations. Par exemple, si les deux opérandes d’un décalage sont connus au moment de la compilation, le compilateur peut optimiser le programme à l’aide d’une routine interne pour précomputer le résultat du décalage, puis en remplaçant le résultat à la place de l’opération de décalage. Si la quantité de décalage est trop importante ou négative, le résultat de la routine interne peut être différent du résultat de la même expression de décalage que celle exécutée par l’UC.
Comportement des arguments variables (varargs)
Dans l’architecture ARM, les paramètres de la liste d’arguments de variable transmis sur la pile sont soumis à l’alignement. Par exemple, un paramètre 64 bits est aligné sur une limite 64 bits. Sur x86 et x64, les arguments transmis sur la pile ne sont pas soumis à un alignement et à un pack serrés. Cette différence peut entraîner une fonction variadicique comme printf
lecture des adresses mémoire destinées au remplissage sur ARM si la disposition attendue de la liste d’arguments variables n’est pas mise en correspondance exactement, même si elle peut fonctionner pour un sous-ensemble de certaines valeurs sur les architectures x86 ou x64. Prenons cet exemple :
// notice that a 64-bit integer is passed to the function, but '%d' is used to read it.
// on x86 and x64 this may work for small values because %d will "parse" the low-32 bits of the argument.
// on ARM the calling convention will align the 64-bit value and the code will print a random value
printf("%d\n", 1LL);
Dans ce cas, le bogue peut être résolu en veillant à ce que la spécification de format correcte soit utilisée afin que l’alignement de l’argument soit pris en compte. Ce code est correct :
// CORRECT: use %I64d for 64-bit integers
printf("%I64d\n", 1LL);
Ordre d’évaluation des arguments
Étant donné que les processeurs ARM, x86 et x64 sont si différents, ils peuvent présenter différentes exigences aux implémentations du compilateur, ainsi que différentes opportunités d’optimisations. En raison de cela, avec d’autres facteurs tels que les paramètres de convention d’appel et d’optimisation, un compilateur peut évaluer les arguments de fonction dans un ordre différent sur différentes architectures ou lorsque les autres facteurs sont modifiés. Cela peut entraîner le comportement d’une application qui s’appuie sur un ordre d’évaluation spécifique pour changer de manière inattendue.
Ce type d’erreur peut se produire lorsque les arguments d’une fonction ont des effets secondaires qui affectent d’autres arguments à la fonction dans le même appel. En règle générale, ce type de dépendance est facile à éviter, mais il peut parfois être masqué par les dépendances difficiles à discerner ou par surcharge d’opérateur. Prenons cet exemple de code :
handle memory_handle;
memory_handle->acquire(*p);
Cela apparaît bien défini, mais si ->
et *
sont des opérateurs surchargés, ce code est traduit en quelque chose qui ressemble à ceci :
Handle::acquire(operator->(memory_handle), operator*(p));
Et s’il existe une dépendance entre operator->(memory_handle)
et operator*(p)
, le code peut s’appuyer sur un ordre d’évaluation spécifique, même si le code d’origine ressemble à l’absence de dépendance possible.
Comportement par défaut de mot clé volatile
Le compilateur MSVC prend en charge deux interprétations différentes du qualificateur de stockage que vous pouvez spécifier à l’aide volatile
de commutateurs du compilateur. Le commutateur /volatile :ms sélectionne la sémantique volatile étendue de Microsoft qui garantit un ordre fort, comme c’est le cas traditionnel pour x86 et x64 en raison du modèle de mémoire forte sur ces architectures. Le commutateur /volatile :iso sélectionne la sémantique volatile standard C++ stricte qui ne garantit pas l’ordre fort.
Sur l’architecture ARM (à l’exception de ARM64EC), la valeur par défaut est /volatile :iso , car les processeurs ARM ont un modèle de mémoire faiblement ordonné, et parce que le logiciel ARM n’a pas d’héritage de s’appuyer sur la sémantique étendue de /volatile :ms et n’a généralement pas besoin d’interface avec les logiciels qui le font. Toutefois, il est parfois pratique ou même nécessaire de compiler un programme ARM pour utiliser la sémantique étendue. Par exemple, il peut être trop coûteux de porter un programme pour utiliser la sémantique ISO C++, ou les logiciels de pilote peuvent devoir adhérer à la sémantique traditionnelle pour fonctionner correctement. Dans ces cas, vous pouvez utiliser le commutateur /volatile :ms ; toutefois, pour recréer la sémantique volatile traditionnelle sur les cibles ARM, le compilateur doit insérer des barrières de mémoire autour de chaque lecture ou écriture d’une volatile
variable pour appliquer un classement fort, ce qui peut avoir un impact négatif sur les performances.
Sur les architectures x86, x64 et ARM64EC, la valeur par défaut est /volatile :ms , car la plupart des logiciels qui ont déjà été créés pour ces architectures à l’aide de MSVC s’appuient sur eux. Lorsque vous compilez des programmes x86, x64 et ARM64EC, vous pouvez spécifier le commutateur /volatile :iso pour éviter toute dépendance inutile sur la sémantique volatile traditionnelle et promouvoir la portabilité.