Décompiler volatile : le modreq sur le champ, le préfixe sur chaque accès, et l'appel Volatile.Read qui semble identique mais ne l'est pas
Le mot-clé volatile n'a pas de runtime propre. Il se compile en deux éléments de métadonnées distincts qui résident à des endroits différents : un modificateur requis, modreq(IsVolatile), soudé dans la signature du champ, et un préfixe volatile. d'un octet apposé devant chaque ldfld et stfld qui touche le champ. Un décompilateur doit lire les deux, s'accorder avec lui-même sur celui qui fait autorité, puis ne pas confondre le résultat avec les deux choses qui semblent identiques en C# mais se compilent de façon complètement différente — Volatile.Read sur un champ ordinaire, qui est un simple appel sans préfixe ni modificateur, et Interlocked.Increment sur un champ volatile, qui prend le champ par adresse et porte donc le modificateur mais pas le préfixe. Cet article parcourt l'IL des trois cas, montre ce que Glass.NET reconstruit à partir de chacun, explique pourquoi le jeton de référence du champ doit lui-même porter le modreq sous peine que l'accès ne se résolve même pas, et couvre le seul cas que C# ne peut pas exprimer — un préfixe volatile. sans champ volatile derrière lui — ainsi que l'appel que Glass émet pour lui.
volatile est l’un des plus petits mots-clés de C# et l’un des plus dispersés en IL. Il ne se compile pas en une instruction. Il se compile en un modificateur sur la signature du champ et en un préfixe sur chaque accès au champ, à deux endroits différents des métadonnées, et un décompilateur doit lire les deux et décider lequel il croit. Il doit ensuite éviter de confondre le mot-clé avec ses deux sosies : Volatile.Read sur un champ qui n’est pas volatile, et Interlocked.Increment sur un champ qui l’est. Les trois se lisent comme un accès thread-safe en C# ; les trois se compilent en IL différents. Cet article parcourt les trois pour Glass.NET.
Un mot-clé, deux éléments de métadonnées
Commençons par la forme ordinaire, un drapeau d’arrêt scruté par une boucle de travail :
public sealed class Worker
{
private volatile bool _stop;
private int _iterations;
public void RequestStop() => _stop = true;
public void Run()
{
while (!_stop) { DoWork(); _iterations++; }
}
}
Regardez d’abord le champ. La déclaration n’est pas bool ; c’est bool revêtu d’un modificateur requis :
.field private bool modreq([System.Runtime]System.Runtime.CompilerServices.IsVolatile) _stop
.field private int32 _iterations
modreq — modificateur requis, par opposition à modopt — signifie que le modificateur fait partie de la signature. Un consommateur qui ne le comprend pas doit refuser de se lier, et c’est pourquoi IsVolatile est un modreq : un compilateur qui l’ignorerait émettrait silencieusement un accès non volatile à un champ volatile. L’effet pratique pour un décompilateur, c’est que chaque référence au champ porte elle aussi le modificateur, car un jeton de référence de champ se résout par le nom et la signature, et bool _stop est une signature différente de bool modreq(IsVolatile) _stop.
Passons aux accès. RequestStop est un stockage, Run est un chargement dans une boucle :
.method public hidebysig instance void RequestStop() cil managed
{
ldarg.0
ldc.i4.1
volatile.
stfld bool modreq([System.Runtime]System.Runtime.CompilerServices.IsVolatile) Worker::_stop
ret
}
.method public hidebysig instance void Run() cil managed
{
IL_0000: br.s IL_0016
IL_0002: ldarg.0
IL_0003: call instance void Worker::DoWork()
IL_0008: ldarg.0
IL_0009: dup
IL_000a: ldfld int32 Worker::_iterations
IL_000f: ldc.i4.1
IL_0010: add
IL_0011: stfld int32 Worker::_iterations
IL_0016: ldarg.0
IL_0017: volatile.
IL_0019: ldfld bool modreq([System.Runtime]System.Runtime.CompilerServices.IsVolatile) Worker::_stop
IL_001e: brtrue.s IL_0002
IL_0020: ret
}
Deux choses à remarquer. Le préfixe volatile. (0xFE 0x13, ce qui explique pourquoi le décalage suivant saute de deux) se place immédiatement avant le ldfld/stfld et ne s’applique qu’à cette seule instruction — c’est l’ordre donné au JIT de ne pas sortir le chargement de la boucle ni de conserver la valeur dans un registre. Et les accès à _iterations juste à côté n’ont aucun préfixe, car ce champ n’a pas de modificateur. Par accès, par champ : voilà le couple.
Ce que Glass lit, et quelle moitié il croit
C# n’a qu’une seule façon de dire « volatile » : le modificateur sur la déclaration. Il n’existe pas de syntaxe pour une lecture non volatile d’un champ volatile, ni de syntaxe pour une lecture volatile d’un champ ordinaire au moyen du préfixe — ce cas doit passer par Volatile.Read, qui est un appel de méthode. Le modificateur au niveau du champ est donc la seule chose que le langage peut exprimer, et c’est celle que Glass.NET prend pour source de vérité.
La reconstruction est donc : lire modreq(IsVolatile) dans la signature du champ → émettre volatile sur la déclaration → attendre un préfixe volatile. sur chaque ldfld/stfld/ldsfld/stsfld direct de ce champ → émettre un accès ordinaire, car le mot-clé sur la déclaration porte déjà la signification. Le résultat est la source dont vous êtes parti :
private volatile bool _stop; // from the modreq on the field
private int _iterations;
public void RequestStop() { _stop = true; } // prefix consumed by the declaration
public void Run()
{
while (!_stop) { DoWork(); _iterations++; } // same
}
Le préfixe n’est pas jeté ; il est vérifié. Si un champ possède le modificateur mais qu’un accès direct n’a pas le préfixe, cet IL ne vient pas du compilateur C# — il vient d’IL écrit à la main, d’un outil de réécriture, ou d’un autre langage — et Glass restitue l’accès tel quel et l’annote, car le promouvoir silencieusement au mot-clé reviendrait à affirmer une barrière qui n’existe pas. L’autre direction, un préfixe sans modificateur, est le cas traité à la fin de cet article.
Premier sosie : Volatile.Read sur un champ qui n’est pas volatile
Voici maintenant la forme que l’on adopte dans du code chaud où une seule lecture doit se synchroniser et où le champ est par ailleurs accédé librement :
private bool _ready; // not volatile
public void Publish() { /* ... */ Volatile.Write(ref _ready, true); }
public bool IsReady => Volatile.Read(ref _ready);
Cela se lit comme un champ volatile avec un mot en plus. L’IL n’a rien de commun avec le premier cas :
.field private bool _ready // no modreq
.method public hidebysig specialname instance bool get_IsReady() cil managed
{
ldarg.0
ldflda bool Worker::_ready // take the ADDRESS — no prefix possible
call bool [System.Runtime]System.Threading.Volatile::Read(bool&)
ret
}
Aucun modificateur sur le champ, aucun préfixe sur aucune instruction. La volatilité réside dans l’appelé, et elle ne s’applique qu’à ce seul accès — chaque autre lecture de _ready dans le type est un simple chargement sans barrière, ce qui est précisément l’intérêt de l’écrire ainsi. Glass.NET conserve l’appel exactement tel qu’écrit : un champ non volatile et un Volatile.Read explicite. Le réduire à private volatile bool _ready; avec une lecture ordinaire serait un changement sémantique — cela imposerait une barrière à chaque autre accès au champ, y compris ceux que l’auteur a délibérément laissés ordinaires. Les deux formes offrent la même garantie à cet endroit et des garanties différentes partout ailleurs, et un décompilateur fidèle préserve la différence.
Deuxième sosie : Interlocked.Increment sur un champ qui est volatile
La troisième forme est celle qui produit un avertissement du compilateur, et l’IL explique l’avertissement mieux que la documentation :
private volatile int _count;
public void Bump() => Interlocked.Increment(ref _count); // warning CS0420
Interlocked.Increment prend ref int. Pour passer un champ par référence, le compilateur doit prendre son adresse, et une adresse n’est ni un chargement ni un stockage — il n’y a rien sur quoi apposer un préfixe volatile. :
.field private int32 modreq([System.Runtime]System.Runtime.CompilerServices.IsVolatile) _count
.method public hidebysig instance void Bump() cil managed
{
ldarg.0
ldflda int32 modreq([System.Runtime]System.Runtime.CompilerServices.IsVolatile) Worker::_count
call int32 [System.Runtime]System.Threading.Interlocked::Increment(int32&)
pop
ret
}
Le jeton ldflda porte bien le modificateur — il le doit, sinon la référence au champ ne se résoudrait pas — mais il n’y a pas de préfixe, car l’accès par la référence se produit à l’intérieur d’Increment, qui effectue une lecture-modification-écriture atomique de toute façon au moins aussi forte qu’un accès volatile. Voilà pourquoi CS0420 (« une référence à un champ volatile ne sera pas traitée comme volatile ») est un avertissement et non une erreur : le code est correct ici, le compilateur ne peut simplement pas le prouver en général.
Glass.NET relit cela comme Interlocked.Increment(ref _count) avec _count toujours déclaré volatile, car le modificateur est toujours sur le champ. L’avertissement a disparu, et il n’est pas récupérable : un avertissement est le commentaire du compilateur, pas une métadonnée, et rien dans l’assembly ne consigne qu’il a un jour été émis. Le code décompilé est le code.
Le cas que C# ne peut pas exprimer
Tout ce qui précède provenait du compilateur C#, qui n’émet jamais le préfixe que sur des accès à des champs modreq. D’autres producteurs sont moins soigneux. L’IL écrit à la main, les outils de réécriture d’IL et d’autres compilateurs peuvent placer volatile. devant un ldfld d’un champ sans modificateur, ou devant ldind.i4/stind.i4/ldobj/stobj sur un pointeur ou une référence managée, et cpblk/initblk l’acceptent aussi :
ldarg.1 // a ref int parameter
volatile.
ldind.i4 // volatile read through a reference — no field, no modreq
C# n’a aucun mot-clé pour cela. La seule construction du langage qui signifie « un accès volatile à un emplacement qui n’est pas un champ volatile » est l’API Volatile, et c’est donc ce que Glass émet :
Volatile.Read(ref value) // what Glass renders for volatile. ldind.i4 on a ref parameter
Ce n’est pas ce que l’auteur d’origine a écrit — il a écrit de l’IL — mais c’est le seul C# qui signifie ce qu’il a écrit, et il se compile en un appel ayant la même sémantique d’acquisition. L’alternative, abandonner le préfixe et restituer un simple déréférencement, retirerait silencieusement une barrière. Lorsque la forme est inhabituelle, Glass préfère la construction qui préserve la garantie à celle qui ressemble à du code source typique.
À retenir
volatile est un modificateur au niveau du champ et un préfixe par accès résidant dans deux parties différentes des métadonnées, et le modificateur est la moitié qu’un décompilateur peut exprimer — Glass.NET lit modreq(IsVolatile) sur le champ, émet le mot-clé sur la déclaration, et traite le préfixe sur chaque chargement et stockage direct comme une confirmation plutôt que comme une information. Les deux sosies conservent leurs propres formes pour de bonnes raisons : Volatile.Read sur un champ ordinaire est une adresse et un appel sans modificateur, et le promouvoir au mot-clé imposerait une barrière à des accès que l’auteur a laissés ordinaires ; Interlocked.Increment sur un champ volatile est une adresse avec le modificateur et sans préfixe, et se restitue tel quel tandis que son avertissement CS0420 n’est tout simplement pas récupérable. Et la seule forme que C# ne peut pas exprimer — un préfixe sans champ volatile derrière lui — ressort sous la forme de l’appel Volatile, car préserver la barrière compte plus que ressembler à du code source typique.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.