Skip to content
← Tous les articles
· Delta1 Labs DecompilationGlass

Comment Glass reconstruit une instruction switch C# à partir d'une table de sauts IL

Un switch C# sur une plage dense d'entiers ne se compile pas en une chaîne de comparaisons : il se compile en un seul opcode switch IL portant une table de cibles de branchement, indexée par la valeur du switch. Cet article détaille à quoi ressemble cette table de sauts, comment le compilateur normalise la valeur en soustrayant le cas minimum, pourquoi les switch épars deviennent des chaînes if/else de recherche binaire, et comment Glass.NET transforme l'enchevêtrement de gotos qui en résulte en un switch (x) { case 10: ... } propre.

Une instruction switch C# ressemble, dans le code source, à un net branchement à voies multiples. Sous le capot, sur une plage dense d’entiers, c’est quelque chose de plus intéressant : une seule instruction IL (l’opcode switch) qui porte une table ordonnée de cibles de saut et distribue en temps constant en utilisant la valeur du switch comme index. Il n’y a aucune comparaison. Cela la rend rapide, et cela transforme la sortie d’un décompilateur naïf en un fouillis de goto et d’étiquettes numérotées. Reconstruire le switch (x) { case 10: ... } propre à partir de cette table est un problème de décompilation petit et autonome, et c’est un bon prisme pour voir ce que fait réellement un décompilateur.

Glass.NET est un décompilateur .NET gratuit et un inspecteur d’assembly doté d’une interface Avalonia au style Visual Studio et d’un outil en ligne de commande glass. Tout ce qui suit relève de l’analyse statique : il lit les métadonnées et les corps de méthodes d’un assembly que vous possédez déjà et que vous êtes en droit d’inspecter, et il ne charge ni n’exécute jamais la cible.

L’opcode switch est une table de sauts

Commençons par un switch dense : les cas forment une plage d’entiers contiguë :

public static string Grade(int score)
{
    switch (score)
    {
        case 10: return "A";
        case 11: return "B";
        case 12: return "C";
        case 13: return "D";
        case 14: return "E";
        default: return "?";
    }
}

Les cinq cas vont de 10 à 14 sans trous. Roslyn n’émet pas cinq comparaisons. Il émet un seul opcode switch dont l’opérande est une table de cinq cibles de saut, précédé de l’arithmétique qui transforme score en un index basé sur zéro :

IL_0000: ldarg.0
IL_0001: ldc.i4.s   10
IL_0003: sub
IL_0004: switch     (IL_001F, IL_0023, IL_0027, IL_002B, IL_002F)
IL_001A: br.s       IL_0033
IL_001F: ldstr      "A"
IL_0021: br.s       IL_0035
IL_0023: ldstr      "B"
IL_0025: br.s       IL_0035
IL_0027: ldstr      "C"
IL_0029: br.s       IL_0035
IL_002B: ldstr      "D"
IL_002D: br.s       IL_0035
IL_002F: ldstr      "E"
IL_0031: br.s       IL_0035
IL_0033: ldstr      "?"
IL_0035: ret

Lisez attentivement la ligne switch, car c’est tout le mécanisme. L’opcode retire un entier non signé de la pile (ici l’index normalisé) et le traite comme une position dans la liste des cibles. L’index 0 branche vers IL_001F, le 1 vers IL_0023, le 2 vers IL_0027, et ainsi de suite. Si l’index est supérieur ou égal à 5 (le nombre d’entrées), l’opcode ne fait rien et l’exécution passe à l’instruction immédiatement suivante, IL_001A, qui est un br.s inconditionnel vers le corps default à IL_0033. Une instruction, une distribution O(1), et une chute intégrée pour default.

Normaliser la valeur du switch

Les cas commencent à 10, mais une table de sauts est toujours indexée à partir de zéro. Ce décalage est comblé par les deux instructions qui précèdent le switch :

ldc.i4.s   10
sub

Le compilateur soustrait l’étiquette de cas minimale (10) à score. Désormais score == 10 devient l’index 0, score == 11 devient l’index 1, jusqu’à score == 14 qui devient l’index 4. La soustraction est aussi ce qui permet à la chute de gérer d’un coup les deux extrémités de la plage default : si score vaut 9, la soustraction donne -1, qui en tant que valeur non signée est 0xFFFFFFFF (énorme, certainement >= 5), donc elle chute vers default. Si score vaut 100, le résultat est 90, également >= 5, même chute. Une seule comparaison non signée, implicite dans l’opcode, couvre tout ce qui est hors de la plage dans les deux directions.

Cette normalisation est la chose la plus importante qu’un décompilateur doive réussir ici. La table de sauts enregistre l’index 0 à l’index 4, mais le code source n’a jamais mentionné 0 ; il disait case 10. Pour reconstruire les étiquettes d’origine, le décompilateur doit trouver la constante soustraite et la rajouter : étiquette de cas = index de table + 10. Si l’on rate le sub, chaque cas reconstruit est décalé du minimum.

valeur du switchscore = 12soustraire le minimum12 - 10 = 2index depuis zéroindex = 2indexdécalage ILétiquette0IL_001Fcase 101IL_0023case 112IL_0027case 12 ← sélectionné3IL_002Bcase 134IL_002Fcase 14≥ 5 ou énorme non signéIL_0033default : chuteétiquette = index de table + minimum soustrait (10). Hors plage (score < 10 ou ≥ 15) déborde en non signé et chute vers default.

Ce que produit un décompilateur naïf

Si un décompilateur s’arrête au graphe de flux de contrôle sans reconnaître le motif, il émettra fidèlement exactement ce que dit l’IL : un branchement calculé vers des étiquettes, la normalisation laissée sous forme d’expression arithmétique. Le résultat est correct mais illisible :

// sortie naïve, sans reconnaître le motif
int num = score - 10;
switch (num)   // ou pire : une cascade de "if (num == 0) goto IL_001F;"
{
    default: goto IL_0033;
    case 0: goto IL_001F;
    case 1: goto IL_0023;
    case 2: goto IL_0027;
    case 3: goto IL_002B;
    case 4: goto IL_002F;
}
IL_001F: return "A";
IL_0023: return "B";
// ...
IL_0033: return "?";

Le num = score - 10 laisse transparaître la normalisation que le compilateur a ajoutée. Les cas sont numérotés 0..4 au lieu de 10..14. Et les corps sont atteints par goto vers des étiquettes plutôt que d’être à l’intérieur du switch. Cela compile et c’est correct, mais cela ne ressemble en rien au code source.

Comment Glass retrouve le switch propre

Glass.NET traite toute la séquence (le chargement, la soustraction, l’opcode switch, le br de chute et les corps cibles) comme un seul idiome reconnaissable, et inverse chaque étape :

  1. Lire la table de sauts. L’opérande de switch est une liste ordonnée de décalages : IL_001F, IL_0023, IL_0027, IL_002B, IL_002F. L’index i correspond au corps au décalage i.
  2. Retrouver la normalisation. Glass voit le ldc.i4.s 10; sub qui alimente la valeur du switch et enregistre le minimum soustrait comme 10. L’étiquette de cas pour l’index de table i est donc i + 10.
  3. Faire correspondre le default. L’instruction immédiatement après le switch est la chute (ici br.s IL_0033). Ce décalage est le corps default. Glass intègre aussi le comportement implicite hors plage dans default plutôt que d’émettre une vérification de bornes explicite.
  4. Attacher les corps. Chaque décalage cible devient un case dont le corps est le code linéaire à ce décalage, le br final vers la sortie commune étant reconnu comme la fin du cas.

La sortie reconstruite est exactement l’originale :

public static string Grade(int score)
{
    switch (score)
    {
        case 10: return "A";
        case 11: return "B";
        case 12: return "C";
        case 13: return "D";
        case 14: return "E";
        default: return "?";
    }
}

Pas de goto, pas de score - 10 qui transparaît, des cas numérotés à partir de 10 comme écrit.

Les switch épars ne sont pas des tables de sauts

La table de sauts n’est rentable que lorsque les cas sont denses. Écartez-les et une table ne serait pour l’essentiel que des emplacements vides : un switch sur 1, 1000 et 100000 nécessiterait une table de cent mille entrées pour loger trois corps. Le compilateur change donc de stratégie. Considérez :

public static string Classify(int code)
{
    switch (code)
    {
        case 1:    return "low";
        case 1000: return "mid";
        case 100000: return "high";
        default:   return "none";
    }
}

Ici Roslyn n’émet aucun opcode switch. Il construit à la place un arbre de recherche binaire de comparaisons, testant d’abord la valeur centrale pour diviser la recherche par deux :

IL_0000: ldarg.0
IL_0001: ldc.i4     1000
IL_0006: bgt.un.s   IL_0015        // code > 1000 ? go check the high half
IL_0008: ldarg.0
IL_0009: ldc.i4.1
IL_000A: beq.s      IL_0024        // code == 1 ?
IL_000C: ldarg.0
IL_000D: ldc.i4     1000
IL_0012: beq.s      IL_002A        // code == 1000 ?
IL_0014: br.s       IL_0036        // default
IL_0015: ldarg.0
IL_0016: ldc.i4     100000
IL_001B: beq.s      IL_0030        // code == 100000 ?
IL_0023: br.s       IL_0036        // default
IL_0024: ldstr      "low"
// ... each body returns
IL_0036: ldstr      "none"
IL_0038: ret

Le bgt.un.s en tête scinde la plage, et les instructions beq.s testent des valeurs individuelles. bgt.un (le supérieur-à non signé) est la même astuce que précédemment : comparer en non signé permet à un seul branchement d’attraper aussi tout ce qui est en dessous du cas, c’est pourquoi vous voyez .un même sur des switch int apparemment signés. Pour un ensemble épars plus grand, l’arbre devient plus profond, mais la forme est toujours cette échelle de comparaisons, jamais la table à opcode unique.

Glass.NET reconstruit aussi celui-ci en l’identique switch (code) { case 1: ... case 1000: ... }. Le travail est différent (pas de table à lire ni de minimum à rajouter ; Glass doit à la place reconnaître l’arbre de comparaisons de recherche binaire et le replier en étiquettes de cas) mais la sortie est le switch C# propre dans les deux cas. Un switch du monde réel se compile souvent en un hybride : les grappes denses d’étiquettes deviennent de petites tables de sauts, et les valeurs isolées éparses deviennent des comparaisons beq greffées sur l’arbre. Glass gère la forme mixte en reconstruisant chaque région avec la stratégie correspondante et en les fusionnant sous un seul switch.

L’essayer avec la CLI glass

Pour voir n’importe quoi de cela par vous-même, pointez la CLI vers le type. La vue C# décompilée vous donne le switch reconstruit :

glass decompile MyApp.dll --type MyApp.Grading.Scorer

Pour voir la mécanique en dessous (l’opcode switch avec sa liste de cibles, la normalisation sub et le br de chute) passez à la vue IL pour une seule méthode :

glass decompile MyApp.dll --type MyApp.Grading.Scorer --member Grade --il

Lire les deux côte à côte est l’essentiel. Le C# vous montre la forme que l’auteur a écrite ; l’IL vous montre si le compilateur a choisi une table de sauts ou une échelle de comparaisons pour l’implémenter. Les deux sont des lectures statiques du même assembly (rien n’est chargé ni exécuté), ce qui en fait un moyen sûr et légitime de comprendre et de déboguer du code compilé que vous êtes en droit d’inspecter, qu’il s’agisse de votre propre build ou d’une bibliothèque tierce dont vous dépendez.

À retenir

Un switch C# dense n’est pas une chaîne de if : c’est une table de sauts distribuée par un seul opcode switch IL, avec la valeur du switch normalisée en soustrayant le cas minimum pour que la table puisse être indexée à partir de zéro, et une chute de plage non signée implicite tenant lieu de default. Un switch épars est l’inverse : une échelle de recherche binaire de comparaisons beq/bgt.un. Un décompilateur naïf vous montre les gotos et la soustraction qui transparaît ; Glass.NET lit la table, rajoute le minimum soustrait, fait correspondre la cible default et vous rend le switch (x) { case 10: ... } propre que l’auteur a réellement écrit.

Essayez Nebula.NET

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