Arithmétique de pointeur 64 bits en C #, vérification du comportement de modification du dépassement de capacité arithmétique

J’ai un code C # non sécurisé qui effectue l’arithmétique de pointeur sur des blocs de mémoire volumineux sur un byte* type byte* , exécuté sur une machine 64 bits. Cela fonctionne correctement la plupart du temps, mais lorsque les choses s’agrandissent, j’ai souvent une corruption qui rend le pointeur incorrect.

La chose étrange est que si j’active “Vérifier les débordements / dépassements arithmétiques” tout fonctionne correctement. Je ne reçois aucune exception de débordement. Mais en raison de l’impact négatif sur les performances, j’ai besoin d’exécuter le code sans cette option.

Qu’est-ce qui pourrait causer cette différence de comportement?

C’est un bogue du compilateur C # ( déposé sur Connect ). @Grant a montré que le MSIL généré par le compilateur C # interprète l’opérande uint comme étant signé. C’est faux selon la spécification C #, voici la section pertinente (18.5.6):

18.5.6 Arithmétique de pointeur

Dans un contexte non sécurisé, les opérateurs + et - (§7.8.4 et §.8.5) peuvent être appliqués aux valeurs de tous les types de pointeurs, à l’exception de void* . Ainsi, pour chaque type de pointeur T* , les opérateurs suivants sont définis implicitement:

 T* operator +(T* x, int y); T* operator +(T* x, uint y); T* operator +(T* x, long y); T* operator +(T* x, ulong y); T* operator +(int x, T* y); T* operator +(uint x, T* y); T* operator +(long x, T* y); T* operator +(ulong x, T* y); T* operator –(T* x, int y); T* operator –(T* x, uint y); T* operator –(T* x, long y); T* operator –(T* x, ulong y); long operator –(T* x, T* y); 

A partir d’une expression P d’un type de pointeur T* et d’une expression N de type int , uint , long ou ulong , les expressions P + N et N + P calculent la valeur du pointeur de type T* résultant de l’ajout de N * sizeof(T) à l’adresse donnée par P De même, l’expression P - N calcule la valeur du pointeur de type T* résultant de la soustraction de N * sizeof(T) de l’adresse donnée par P

Étant donné deux expressions, P et Q , d’un type de pointeur T* , l’expression P – Q calcule la différence entre les adresses données par P et Q , puis divise cette différence par sizeof(T) . Le type du résultat est toujours long . En effet, P - Q est calculé comme suit: ((long)(P) - (long)(Q)) / sizeof(T) .

Si une opération arithmétique de pointeur déborde du domaine du type pointeur, le résultat est tronqué de la manière définie par l’implémentation, mais aucune exception n’est générée.


Vous êtes autorisé à append un uint à un pointeur, aucune conversion implicite n’a lieu. Et l’opération ne déborde pas le domaine du type pointeur. Donc, la troncature n’est pas autorisée.

La différence entre coché et non coché ici est en fait un bogue dans l’IL, ou tout simplement un code source incorrect (je ne suis pas un expert en langage, donc je ne ferai pas de commentaire sur le fait que le compilateur C # génère l’IL correct pour l’ambigu code source). J’ai compilé ce code de test en utilisant la version 4.0.30319.1 du compilateur C # (bien que la version 2.0 semblait faire la même chose). Les options de ligne de commande que j’ai utilisées sont les suivantes: / o + / unsafe / debug: pdbonly.

Pour le bloc non contrôlé, nous avons ce code IL:

 //000008: unchecked //000009: { //000010: Console.WriteLine("{0:x}", (long)(testPtr + offset)); IL_000a: ldstr "{0:x}" IL_000f: ldloc.0 IL_0010: ldloc.1 IL_0011: add IL_0012: conv.u8 IL_0013: box [mscorlib]System.Int64 IL_0018: call void [mscorlib]System.Console::WriteLine(ssortingng, object) 

Au décalage IL 11, l’addition obtient 2 opérandes, l’un de type octet * et l’autre de type uint32. Selon les spécifications de la CLI, ces valeurs sont réellement normalisées en natif, respectivement int et int32. Selon la spécification CLI (la partition III pour être précis), le résultat sera natif int. Ainsi, l’opérande secodn doit être promu pour être de type native int. Selon les spécifications, ceci est accompli via une extension de signe. Donc, la valeur uint.MaxValue (qui est 0xFFFFFFFF ou -1 en notation signée) est étendue au signe 0xFFFFFFFFFFFFFFFF. Ensuite, les 2 opérandes sont ajoutés (0x0000000008000000L + (-1L) = 0x0000000007FFFFFFL). L’opcode conv n’est nécessaire qu’à des fins de vérification pour convertir l’entier int natif en un int64, qui dans le code généré est un nop.

Maintenant, pour le bloc vérifié, nous avons cet IL:

 //000012: checked //000013: { //000014: Console.WriteLine("{0:x}", (long)(testPtr + offset)); IL_001d: ldstr "{0:x}" IL_0022: ldloc.0 IL_0023: ldloc.1 IL_0024: add.ovf.un IL_0025: conv.ovf.i8.un IL_0026: box [mscorlib]System.Int64 IL_002b: call void [mscorlib]System.Console::WriteLine(ssortingng, object) 

Il est pratiquement identique à l’exception de l’opcode add et conv. Pour l’opcode add, nous avons ajouté 2 ‘suffixes’. Le premier est le suffixe “.ovf” qui a une signification évidente: vérifiez le débordement, mais il est également nécessaire d’activer le deuxième suffixe: “.un”. (c’est-à-dire qu’il n’y a pas “add.un”, seulement “add.ovf.un”). Le “.un” a 2 effets. La plus évidente est que la vérification supplémentaire du débordement est effectuée comme si les opérandes étaient des entiers non signés. Dans nos classes CS il y a bien longtemps, nous espérons tous que, grâce au codage binary à complément à deux, l’addition signée et l’addition non signée sont identiques, le “.un” n’a donc qu’une incidence sur la vérification du débordement, n’est-ce pas?

Faux.

N’oubliez pas que sur la stack IL, nous n’avons pas 2 nombres de 64 bits, nous avons un int32 et un int natif (après normalisation). Eh bien, le “.un” signifie que la conversion de int32 en natif est traitée comme un “conv.u” plutôt que la valeur par défaut “conv.i” comme ci-dessus. Ainsi, uint.MaxValue est zéro étendu à 0x00000000FFFFFFFFL. Ensuite, l’ajout produit correctement 0x0000000107FFFFFFL. L’opcode conv permet de s’assurer que l’opérande non signé peut être représenté comme un int64 signé (ce qu’il peut).

Votre solution ne fonctionne que pour 64 bits. Au niveau de l’IL, un correctif plus correct consisterait à convertir explicitement l’opérande uint32 en int native ou en unsigned int, puis la vérification et l’option non vérifiée se dérouleraient de manière identique pour les versions 32 bits et 64 bits.

Veuillez vérifier votre code dangereux. La lecture ou l’écriture de mémoire en dehors du bloc de mémoire alloué provoque cette “corruption”.

Je réponds à ma propre question car j’ai résolu le problème, mais j’aimerais quand même lire des commentaires sur les raisons pour lesquelles le comportement change avec checked ou unchecked .

Ce code illustre le problème ainsi que la solution (en convertissant toujours l’offset long avant d’append):

 public static unsafe void Main(ssortingng[] args) { // Dummy pointer, never dereferenced byte* testPtr = (byte*)0x00000008000000L; uint offset = uint.MaxValue; unchecked { Console.WriteLine("{0:x}", (long)(testPtr + offset)); } checked { Console.WriteLine("{0:x}", (long)(testPtr + offset)); } unchecked { Console.WriteLine("{0:x}", (long)(testPtr + (long)offset)); } checked { Console.WriteLine("{0:x}", (long)(testPtr + (long)offset)); } } 

Cela retournera (lorsqu’il est exécuté sur une machine 64 bits):

 7ffffff 107ffffff 107ffffff 107ffffff 

(BTW, dans mon projet, j’écrivais tout le code en tant que code géré sans toute cette arnaque arithmétique dangereuse mais découvrais qu’il utilisait trop de mémoire. C’est juste un projet pour hobby; le seul qui soit blessé s’il explose moi.)