Skip to content
← Tous les articles
· Delta1 Labs Glass.NETDécompilation.NETAnalyse approfondie

Décompiler une constante decimal : pourquoi 3.14m n'est pas une constante IL, et l'attribut [DecimalConstant] qui la reconstruit

Tous les autres littéraux numériques en C# — 42, 3.14, 1_000_000L — atterrissent dans l'IL sous la forme d'une instruction ldc ou dans la table de métadonnées Constant, une valeur que le runtime charge directement. decimal ne le peut pas : c'est une struct de 128 bits, pas un primitif pour lequel le CLR dispose d'un opcode de chargement, si bien qu'un const decimal ou un paramètre decimal par défaut n'a aucun endroit natif où vivre, et le compilateur résout le problème par une astuce qui piège quiconque lit l'IL brut — il encode le nombre dans un [DecimalConstantAttribute] dont les cinq arguments de constructeur (échelle, signe et trois entiers 32 bits) épellent la valeur exacte. Cet article suit un décompilateur à travers cette reconstruction : pourquoi la table Constant refuse decimal, comment les arguments de l'attribut correspondent aux quatre int dont un decimal est construit, pourquoi un champ const decimal n'a d'initialiseur dans aucun constructeur, en quoi un paramètre decimal par défaut diffère d'un paramètre int par défaut, et pourquoi prendre l'attribut pour une annotation ordinaire transforme 3.14m en new decimal(314, 0, 0, false, 2) au lieu d'un littéral.

Prenez la ligne de C# la plus ordinaire qui soit :

public const decimal TaxRate = 0.075m;

Compilez-la, ouvrez l’assembly et cherchez où 0.075 est stocké. Ce n’est pas dans l’IL — il n’existe aucune instruction ldc pour cela. Ce n’est pas dans la table de métadonnées Constant, où vivent les const int et les valeurs par défaut des paramètres. Le nombre a, en apparence, disparu. Ce n’est pas le cas : il a été démonté en cinq entiers et rangé dans un attribut, et un décompilateur qui ne sait pas qu’il doit regarder là rendra votre taux de taxe sous la forme d’un tas d’arguments de constructeur. C’est le seul littéral numérique de C# qu’un décompilateur doit reconstruire plutôt que lire, et le mécanisme mérite d’être vu en entier.

Pourquoi decimal n’a nulle part où vivre

Tout autre littéral numérique a un foyer natif. Un const int ou un paramètre int par défaut est écrit dans la table de métadonnées Constant, dont le format de blob définit un encodage pour chaque type d’élément primitif — ELEMENT_TYPE_I4, ELEMENT_TYPE_R8, ELEMENT_TYPE_STRING, et ainsi de suite. Un littéral dans un corps de méthode devient une instruction : ldc.i4 42, ldc.r8 3.14. Les deux canaux partagent une hypothèse — que la valeur est d’un type que le CLR représente nativement.

decimal ne l’est pas. System.Decimal est une struct de 128 bits définie dans la bibliothèque de classes, construite à partir d’un entier de 96 bits plus une échelle et un signe. Le CLR n’a aucun opcode de chargement pour elle et le format de blob Constant n’a aucun type d’élément pour elle. Ainsi, quand le langage dit « ce decimal est une constante », aucune case de métadonnées ne peut contenir la réponse. Le compilateur doit encoder la valeur autrement — et la voie qu’il a choisie est System.Runtime.CompilerServices.DecimalConstantAttribute.

L’attribut est le nombre

Voici le champ ci-dessus tel que le compilateur l’émet, en IL :

.field public static initonly valuetype [System.Runtime]System.Decimal TaxRate
  .custom instance void [System.Runtime]System.Runtime.CompilerServices.DecimalConstantAttribute::.ctor(
      uint8, uint8, uint32, uint32, uint32) = ( 01 00 03 00 00 00 00 00 00 00 00 00 4B 00 00 00 00 00 )

Ces cinq arguments de constructeur ne sont pas des métadonnées à propos du nombre — ils sont le nombre, exactement dans les pièces à partir desquelles System.Decimal est assemblé. La signature du constructeur est (byte scale, byte sign, uint hi, uint mid, uint low) :

  • scale = 3 — le nombre de chiffres après la virgule (diviser l’entier par 10³).
  • sign = 0 — positif (1 signifierait négatif).
  • hi / mid / low = 0 / 0 / 75 — les trois mots de 32 bits de l’entier non mis à l’échelle de 96 bits ; ici simplement 75 dans le mot bas.

Mis bout à bout : l’entier non mis à l’échelle 75, à l’échelle 10⁻³, positif → 0.075. Le décompilateur effectue la même arithmétique que le constructeur decimal(int lo, int mid, int hi, bool isNegative, byte scale) du runtime, et retrouve le littéral 0.075m. Rien n’est arrondi et rien n’est perdu — l’attribut est une description complète et exacte de la valeur.

DecimalConstantAttribute → littéralchamp constconst decimal TaxRate[DecimalConstant(...)]scale = 3sign = 0hi = 0mid = 0low = 75réassembler System.Decimalhi·mid·low → entier 75sign 0 → positifscale 3 → ÷ 10³75 × 10⁻³reconstruit0.075mcontraste : une constante intconst int N = 75 vit directement dans la table Constant — ldc.i4 / une ligne Constant.Seul decimal a besoin de l'attribut, car il n'a de case dans aucun des deux canaux.

Un const decimal n’a pas d’initialiseur — volontairement

Une deuxième surprise attend le décompilateur qui ne lit que les corps de méthodes. On pourrait s’attendre à ce que const decimal TaxRate = 0.075m compile en un store dans le constructeur statique — le .cctor affecte le champ — mais ce n’est pas le cas. const en C# signifie constante de compilation : la valeur doit être utilisable dans d’autres contextes constants, donc le compilateur ne peut pas l’implémenter en exécutant du code à l’initialisation du type (cela en ferait un readonly, pas un const). À la place :

  • le champ est émis comme un champ static initonly portant l’attribut [DecimalConstant], et
  • chaque utilisation de TaxRate dans le source est remplacée, à la compilation, par la valeur reconstruite inlinée au site d’appel.

Le .cctor ne contient donc aucun store vers TaxRate, et il n’y a pas d’initialiseur d’instance. Un décompilateur qui parcourt les constructeurs à la recherche de la valeur du champ ne trouve rien et signalerait à tort le champ comme non initialisé. La valeur est dans l’attribut, et retrouver la déclaration originale signifie la lire là — puis présenter le champ comme const, et non static readonly, parce que le drapeau initonly et l’attribut [DecimalConstant] sont ensemble la signature d’un const decimal.

Un paramètre decimal par défaut emprunte une autre route qu’un int par défaut

Le même vide remodèle les paramètres optionnels. Comparez :

void A(int    rate = 5)      { }
void B(decimal rate = 0.075m) { }

Pour A, la valeur par défaut 5 va dans la table Constant et le paramètre reçoit le drapeau HasDefault ; un décompilateur lit le blob Constant et écrit = 5. Pour B, la table Constant ne peut pas contenir un decimal, donc le compilateur marque le paramètre [opt] (le drapeau Optional) sans entrée HasDefault, et attache [DecimalConstant(3, 0, 0, 0, 75)] au paramètre :

.method ... void B(valuetype System.Decimal rate)
{
  .param [1]
    .custom instance void ...DecimalConstantAttribute::.ctor(uint8,uint8,uint32,uint32,uint32)
      = ( 01 00 03 00 00 00 00 00 00 00 00 00 4B 00 00 00 00 00 )
}

Un décompilateur qui retrouve les valeurs par défaut a donc besoin de deux chemins de code : lire la table Constant pour les primitifs, et vérifier la présence de [DecimalConstant] sur le paramètre pour decimal. N’honorez que la table Constant et la valeur par défaut du decimal disparaît — le paramètre est rendu comme n’en ayant aucune. Pire, un décompilateur qui traite [DecimalConstant] comme un attribut personnalisé ordinaire l’imprimera littéralement dans le source :

// WRONG — the attribute is an encoding, not something the author wrote:
void B([DecimalConstant(3, 0, 0, 0, 75)] decimal rate) { }

Cette ligne n’a jamais existé dans le code original. L’attribut est l’encodage privé du compilateur pour = 0.075m, et un décompilateur fidèle le consomme et rend ce que le programmeur a réellement tapé.

Pourquoi Glass le traite comme une valeur, pas comme une annotation

La règle qu’un décompilateur doit encoder est : [DecimalConstant] n’est jamais affiché — il est toujours consommé. Partout où il apparaît — sur un champ const, un champ static readonly initialisé à partir d’une constante, ou un paramètre optionnel — Glass.NET lit les cinq arguments, réassemble le System.Decimal et émet le littéral que l’auteur a écrit : const decimal TaxRate = 0.075m;, decimal rate = 0.075m. L’attribut lui-même est supprimé, exactement comme l’attribut [Extension] sur une méthode d’extension ou le modreq [IsReadOnly] sur une readonly struct sont supprimés — c’est la comptabilité du compilateur, pas le source.

C’est une petite fonctionnalité avec un mode d’échec démesuré. Faites-le mal et chaque constante monétaire, taux de taxe et seuil d’une application financière décompilée revient sous la forme new decimal(75, 0, 0, false, 3) ou, pire, d’un attribut que le programmeur n’a jamais écrit — techniquement correct, humainement illisible. Faites-le bien et 0.075m se relit 0.075m, ce qui, pour le seul type numérique qui n’a aucun foyer natif dans les métadonnées, est tout le travail.

Voyez comment Glass gère les autres valeurs que le compilateur cache dans les métadonnées plutôt que dans l’IL dans décompiler les arguments optionnels et nommés et décompiler un initialiseur de tableau.

Essayez Nebula.NET

Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.