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

Décompiler un switch sur des chaînes : la table de saut par hachage cachée que construit le compilateur

Un switch sur un entier se compile en une seule instruction switch IL. Un switch sur une chaîne ne le peut pas — il n'existe pas de table de saut pour du texte — alors dès que vous avez plus d'une poignée de cas, le compilateur C# le réécrit discrètement en un aiguillage à deux étages : hacher la chaîne avec un ComputeStringHash synthétisé, brancher selon le hachage vers un candidat, puis confirmer avec un String.Equals ordinal avant de sauter au bras. Ce billet lit l'IL réel d'un grand switch sur chaînes, montre l'assistant FNV <PrivateImplementationDetails>.ComputeStringHash et le second switch dense sur un index de cas, explique pourquoi le contrôle d'égalité n'est pas optionnel, et parcourt comment Glass.NET reconnaît toute la forme et la replie en switch (s) { case "...": } au lieu de vous laisser une page de constantes de hachage.

Un switch sur un int ou un enum est l”une des choses les plus propres de tout l”IL : le compilateur émet une seule instruction switch, une véritable table de saut qui lit un entier sur la pile et branche vers la N-ième étiquette en temps constant. Un switch sur une chaîne a l”air identique dans le source — case "build": côtoie case 2: dans le langage — mais ne peut pas se compiler vers la même instruction, car il n”existe pas de table de saut indexée par du texte.

Alors le compilateur le réécrit. Pour deux ou trois cas il fait l”évident et émet une courte échelle de String.Equals. Mais dès qu”un switch sur chaînes dépasse une poignée d”étiquettes, Roslyn adopte une stratégie différente que la plupart des gens n”ont jamais vue : il hache la chaîne, branche selon le hachage pour trouver un candidat, confirme le candidat par un contrôle d”égalité, puis utilise un vrai switch entier sur un petit index de cas pour atteindre le bras. Le lisible switch (command) que vous avez écrit devient une page de constantes hexadécimales et d”appels à op_Equality — et un décompilateur soit comprend cette forme et vous rend votre switch, soit vous tend l”hexadécimal.

commandla chaîne d''entréeComputeStringHashFNV-1a → uint32étage 1branch on hash+ op_Equalityfixe l''index de casétage 2switch (index)table de saut IL densearm bodies 1..Ndefault arm

Un switch assez grand pour le déclencher

Voici un répartiteur de commandes ordinaire — un switch classique sur chaînes avec huit cas :

public static int Dispatch(string command)
{
    switch (command)
    {
        case "init":    return 1;
        case "build":   return 2;
        case "restore": return 3;
        case "run":     return 4;
        case "test":    return 5;
        case "pack":    return 6;
        case "publish": return 7;
        case "clean":   return 8;
        default:        return 0;
    }
}

Huit étiquettes est confortablement au-delà du seuil (Roslyn passe à la forme hachée vers sept), alors aucune de ces chaînes de case ne survit comme une simple échelle de comparaisons. À la place, le compilateur injecte d”abord un assistant dans un type synthétique nommé <PrivateImplementationDetails> :

.method assembly hidebysig static uint32
    '<PrivateImplementationDetails>'::ComputeStringHash(string s) cil managed
{
    // uint num = 2166136261;                 // FNV-1a offset basis (0x811C9DC5)
    // for (int i = 0; i < s.Length; i++)
    //     num = (s[i] ^ num) * 16777619;     // FNV-1a prime   (0x01000193)
    // return num;
}

C”est un hachage FNV-1a 32 bits de manuel et rien de plus. Il est rapide, il n”est pas cryptographique, et il est explicitement autorisé à entrer en collision — la gestion des collisions vient plus tard. Sa présence dans <PrivateImplementationDetails>, avec exactement ce corps, est le signal le plus clair qu”une méthode contient un switch sur chaînes haché.

L”aiguillage à deux étages en IL

Maintenant le corps de Dispatch. L”abaissement utilise deux variables locales — appelons-les num (le hachage) et num2 (un index de cas dense) — et aiguille en deux étages :

// ---- stage 0: hash the input ----
ldarg.0
call    uint32 '<PrivateImplementationDetails>'::ComputeStringHash(string)
stloc.0                         // num = ComputeStringHash(command)

// ---- stage 1: branch on the hash, confirm with Equals, set the case index ----
ldloc.0
ldc.i4  0x9B8C...               // == ComputeStringHash("init")
beq.s   TRY_INIT
ldloc.0
ldc.i4  0x4F2A...               // == ComputeStringHash("build")
beq.s   TRY_BUILD
// ... one comparison per case ...
br      DEFAULT

TRY_INIT:
ldarg.0
ldstr   "init"
call    bool [System.Runtime]System.String::op_Equality(string, string)
brtrue.s  SET_0
br      DEFAULT
SET_0:  ldc.i4.0
        stloc.1                 // num2 = 0  (the "init" arm index)
        br.s    DISPATCH

TRY_BUILD:
ldarg.0
ldstr   "build"
call    bool [System.Runtime]System.String::op_Equality(string, string)
brtrue.s  SET_1
br      DEFAULT
SET_1:  ldc.i4.1
        stloc.1                 // num2 = 1
        br.s    DISPATCH
// ... and so on ...

// ---- stage 2: a real integer switch over the dense case index ----
DISPATCH:
ldloc.1
switch  (ARM_INIT, ARM_BUILD, ARM_RESTORE, ARM_RUN, ARM_TEST, ARM_PACK, ARM_PUBLISH, ARM_CLEAN)
br      DEFAULT

Lisez ce qui se passe réellement. L”étage 0 transforme la chaîne en un nombre 32 bits. L”étage 1 compare ce nombre au hachage précalculé de chaque littérale de cas ; un succès ne saute pas au bras — il saute à une confirmation qui charge la chaîne originale, la compare à la littérale avec op_Equality, et en cas de succès écrit un petit index contigu dans num2. Seul l”étage 2 utilise la véritable instruction switch — et à ce moment il aiguille sur 0..7, une plage entière dense, ce qui est exactement ce en quoi l”opcode switch excelle.

La branche de hachage de l”étage 1 est émise ici comme une séquence de comparaisons beq parce que les huit hachages sont dispersés sur la plage de uint. Quand les hachages se regroupent, Roslyn émettra un second switch sur le hachage aussi — alors un grand switch sur chaînes peut se décompiler en deux tables de saut imbriquées, ce qui est encore plus déroutant si vous ne connaissez pas le motif.

Pourquoi le contrôle d”égalité est tout l”intérêt

L”instruction la plus importante de ce listing est celle qui semble redondante : l”op_Equality après que le hachage a déjà correspondu. Elle n”est pas redondante, et la supprimer serait un bug.

ComputeStringHash perd de l”information — il mappe arbitrairement de nombreuses chaînes sur 2³² valeurs, alors deux chaînes différentes peuvent partager un hachage. La branche de hachage ne réduit l”entrée qu”à un candidat ; c”est le String.Equals ordinal qui prouve que l”entrée est réellement ce cas avant que son bras ne s”exécute. Deux conséquences en découlent directement :

  • Une collision de hachage est gérée, pas crainte. Si deux littérales de cas entrent en collision, le compilateur émet les deux contrôles op_Equality sous la même branche de hachage ; le premier à correspondre gagne, et un quasi-succès qui partage le hachage avec un vrai cas échoue tout de même à son op_Equality et retombe sur default.
  • Un switch sur chaînes est ordinal et sensible à la casse. op_Equality sur string est String.Equals — une comparaison octet par octet, pas une comparaison sensible à la culture. "Build" ne correspond pas à case "build":, et ne l”a jamais fait ; l”abaissement rend simplement la raison visible. S”il vous faut un aiguillage insensible à la casse, vous mettez l”entrée en minuscules d”abord ou vous aiguillez sur autre chose, car le code généré n”a aucune notion de culture.

Comment Glass.NET le relit

Un décompilateur qui parcourt cet IL instruction par instruction, sans modèle du motif, produit quelque chose de techniquement correct et totalement illisible : une variable locale uint affectée depuis un appel à un mystérieux ComputeStringHash, un arbre de comparaisons if (num == 0x9B8C...) contre des nombres magiques, une poignée de contrôles d”égalité de chaînes, et un switch final sur un entier qui apparaît de nulle part. Personne lisant cela ne peut voir que cela a commencé sa vie comme switch (command).

Glass.NET reconnaît la forme comme un tout. La recette qu”il apparie est assez spécifique pour être fiable :

  1. Une variable locale affectée depuis un appel à une méthode nommée ComputeStringHash sur <PrivateImplementationDetails> dont le corps est la boucle FNV-1a.
  2. Un aiguillage sur cette locale — une échelle de comparaisons ou un switch interne — dont les cibles effectuent chacune un op_Equality de la chaîne originale contre une littérale ldstr.
  3. Chaque correspondance confirmée affectant un index dense et convergeant vers un switch final sur cet index, ou branchant directement vers un corps de bras.

Quand les trois s”alignent, Glass écarte entièrement les hachages — ce sont un détail d”implémentation du compilateur, pas de l”information — et reconstruit le switch à partir des opérandes ldstr, qui sont les littérales de cas d”origine, préservées à l”identique dans les métadonnées. Vous récupérez :

switch (command)
{
    case "init":    return 1;
    case "build":   return 2;
    // ...
    default:        return 0;
}

Les constantes qu”un outil naïf laisserait échouées sur la page — 0x9B8C..., 0x4F2A... — n”apparaissent jamais, car Glass comprend qu”elles sont une fonction des littérales qu”il a déjà récupérées.

Où la reconstruction peut dériver

Comme le motif est régulier, le cas commun fait l”aller-retour proprement, mais quelques formes s”abaissent vers un IL qui se relit comme du code équivalent mais différent, et il vaut la peine de les connaître pour faire confiance au décompilateur là où il est fidèle et lire attentivement là où il paraphrase :

  • Un cas null est géré avant que le hachage ne soit calculé — on ne peut pas hacher null — alors il apparaît comme un brfalse vers le bras null tout en haut, devant l”appel à ComputeStringHash. Glass le replie en case null:, mais dans l”IL brut il se tient curieusement loin du reste du switch.
  • Une collision de hachage entre deux littérales de cas réelles produit deux contrôles op_Equality imbriqués sous une branche de hachage ; un décompilateur peut rendre ce seau par des if imbriqués plutôt que deux case plats. Même comportement, forme légèrement différente.
  • goto case "x"; se compile en une branche de retour vers l”aiguillage — souvent directement vers le bras de l”étage 2 ou vers l”affectation d”index de cas — qu”un décompilateur reconstruit comme le goto case, mais seulement s”il suit que la cible de la branche est dans le même switch abaissé.
  • Un switch utilisé comme expression (la forme d”expression switch sur des constantes de chaîne) s”abaisse à travers la même machinerie ComputeStringHash ; la différence est seulement si les bras produisent une valeur ou exécutent des instructions, ce qui est une étape de reconstruction distincte.

À retenir

Un switch sur une chaîne est l”un des rares endroits où une seule ligne de C# se déploie en une quantité véritablement surprenante d”IL : un assistant de hachage FNV-1a synthétisé, une branche par hachage, une confirmation d”égalité ordinale pour chaque candidat, et une table de saut entière dense — deux aiguillages avec un contrôle de correction coincé au milieu. Le hachage est là pour la vitesse, l”op_Equality est là pour la correction, et ni l”un ni l”autre n”est de l”information que vous voulez lire. Le travail d”un décompilateur est de reconnaître la machine entière, de jeter les hachages, de récupérer les littérales des opérandes ldstr, et de vous rendre le switch que vous avez écrit. C”est la différence entre lire switch (command) { case "build": ... } et plisser les yeux devant un mur d”hexadécimal.

Voir aussi décompiler la correspondance de motifs et les expressions switch de C# pour le switch de correspondance de motifs qui s”abaisse par des tests de type plutôt que par des hachages, et comment fonctionnent les tables de métadonnées .NET pour savoir où vivent réellement le type <PrivateImplementationDetails> et son assistant injecté. Essayez Glass.NET gratuitement sur un assembly à vous et regardez un switch sur chaînes redevenir lisible.

Essayez Nebula.NET

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