Skip to content
← Todas las publicaciones
· Delta1 Labs Glass.NETDecompilación.NETAnálisis a fondo

Decompilar un switch sobre cadenas: la tabla de salto por hash oculta que construye el compilador

Un switch sobre un entero se compila en una única instrucción switch de IL. Un switch sobre una cadena no puede —no hay tabla de salto para texto—, así que en cuanto tienes más de un puñado de casos el compilador de C# lo reescribe en silencio como un despacho de dos etapas: calcula un hash de la cadena con un ComputeStringHash sintetizado, ramifica según el hash hacia un candidato y luego confirma con un String.Equals ordinal antes de saltar al caso. Esta entrada lee el IL real de un switch grande sobre cadenas, muestra el ayudante FNV <PrivateImplementationDetails>.ComputeStringHash y el segundo switch denso sobre un índice de caso, explica por qué la comprobación de igualdad no es opcional y recorre cómo Glass.NET reconoce toda la forma y la vuelve a plegar en switch (s) { case "...": } en lugar de dejarte una página de constantes hash.

Un switch sobre un int o un enum es de lo más limpio de todo IL: el compilador emite una única instrucción switch, una auténtica tabla de salto que lee un entero de la pila y ramifica a la N-ésima etiqueta en tiempo constante. Un switch sobre una cadena se ve igual en el código —case "build": va justo al lado de case 2: en el lenguaje— pero no puede compilarse a la misma instrucción, porque no existe tal cosa como una tabla de salto indexada por texto.

Así que el compilador lo reescribe. Para dos o tres casos hace lo obvio y emite una escalera corta de String.Equals. Pero en cuanto un switch sobre cadenas crece más allá de un puñado de etiquetas, Roslyn recurre a una estrategia distinta que la mayoría nunca ha visto: hashea la cadena, ramifica según el hash para encontrar un candidato, confirma el candidato con una comprobación de igualdad y luego usa un switch entero real sobre un pequeño índice de caso para llegar al caso. El legible switch (command) que escribiste se convierte en una página de constantes hexadecimales y llamadas a op_Equality —y un decompilador o entiende esa forma y te devuelve tu switch, o te entrega el hexadecimal.

commandla cadena de entradaComputeStringHashFNV-1a → uint32etapa 1branch on hash+ op_Equalityfija el índice de casoetapa 2switch (index)tabla de salto IL densaarm bodies 1..Ndefault arm

Un switch lo bastante grande para dispararlo

Aquí hay un despachador de comandos corriente —un switch clásico sobre cadenas con ocho casos:

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;
    }
}

Ocho etiquetas está cómodamente por encima del umbral (Roslyn cambia a la forma con hash alrededor de siete), así que ninguna de estas cadenas de case sobrevive como una simple escalera de comparaciones. En su lugar, el compilador primero inyecta un ayudante en un tipo sintético llamado <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;
}

Eso es un hash FNV-1a de 32 bits de libro de texto y nada más. Es rápido, no es criptográfico y se le permite colisionar explícitamente —el manejo de colisiones viene después—. Su presencia en <PrivateImplementationDetails>, con exactamente este cuerpo, es la señal más clara de que un método contiene un switch sobre cadenas con hash.

El despacho de dos etapas en IL

Ahora el cuerpo de Dispatch. El rebajado usa dos locales —llamémoslas num (el hash) y num2 (un índice de caso denso)— y despacha en dos etapas:

// ---- 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

Lee lo que ocurre de verdad. La etapa 0 convierte la cadena en un número de 32 bits. La etapa 1 compara ese número con el hash precalculado de cada literal de caso; un acierto no salta al caso —salta a una confirmación que carga la cadena original, la compara con la literal con op_Equality y, si tiene éxito, escribe un pequeño índice contiguo en num2—. Solo la etapa 2 usa la auténtica instrucción switch —y para entonces está conmutando sobre 0..7, un rango entero denso, que es exactamente en lo que el opcode switch es bueno.

La ramificación por hash de la etapa 1 se emite aquí como una secuencia de comparaciones beq porque los ocho hashes están dispersos por el rango de uint. Cuando los hashes resultan agruparse, Roslyn emitirá un segundo switch también sobre el hash —así que un switch grande sobre cadenas puede decompilarse a dos tablas de salto anidadas, lo cual es aún más desconcertante si no conoces el patrón.

Por qué la comprobación de igualdad es todo el asunto

La instrucción más importante de ese listado es la que parece redundante: el op_Equality después de que el hash ya coincidiera. No es redundante, y quitarla sería un error.

ComputeStringHash pierde información —mapea arbitrariamente muchas cadenas sobre 2³² valores, así que dos cadenas distintas pueden compartir un hash—. La ramificación por hash solo reduce la entrada a un candidato; es el String.Equals ordinal el que prueba que la entrada realmente es ese caso antes de que su cuerpo se ejecute. De esto se desprenden dos consecuencias directamente:

  • Una colisión de hash se maneja, no se teme. Si dos literales de caso colisionan, el compilador emite ambas comprobaciones op_Equality bajo la misma ramificación por hash; la primera en coincidir gana, y un casi-acierto que comparte el hash con un caso real igualmente falla su op_Equality y cae a default.
  • Un switch sobre cadenas es ordinal y distingue mayúsculas. op_Equality sobre string es String.Equals —una comparación byte a byte, no una sensible a la cultura—. "Build" no coincide con case "build":, y nunca lo hizo; el rebajado simplemente hace visible la razón. Si necesitas despacho insensible a mayúsculas, pasas la entrada a minúsculas primero o conmutas sobre otra cosa, porque el código generado no tiene noción de cultura.

Cómo lo lee de vuelta Glass.NET

Un decompilador que recorre este IL instrucción a instrucción, sin modelo del patrón, produce algo técnicamente correcto y completamente ilegible: una local uint asignada desde una llamada a un misterioso ComputeStringHash, un árbol de comparaciones if (num == 0x9B8C...) contra números mágicos, un rociado de comprobaciones de igualdad de cadenas y un switch final sobre un entero que aparece de la nada. Nadie que lea eso puede ver que empezó su vida como switch (command).

Glass.NET reconoce la forma como una unidad. La receta que empareja es lo bastante específica para ser fiable:

  1. Una local asignada desde una llamada a un método llamado ComputeStringHash en <PrivateImplementationDetails> cuyo cuerpo es el bucle FNV-1a.
  2. Un despacho sobre esa local —una escalera de comparaciones o un switch interno— cuyos destinos cada uno realiza un op_Equality de la cadena original contra una literal ldstr.
  3. Cada coincidencia confirmada asigna un índice denso y converge en un switch final sobre ese índice, o ramifica directo a un cuerpo de caso.

Cuando los tres encajan, Glass descarta los hashes por completo —son un detalle de implementación del compilador, no información— y reconstruye el switch a partir de los operandos ldstr, que son las literales de caso originales, preservadas al pie de la letra en los metadatos. Recuperas:

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

Las constantes que una herramienta ingenua dejaría varadas en la página —0x9B8C..., 0x4F2A...— nunca aparecen, porque Glass entiende que son una función de las literales que ya recuperó.

Dónde puede desviarse la reconstrucción

Como el patrón es regular, el caso común gira limpiamente, pero unas pocas formas se rebajan a IL que se lee de vuelta como código equivalente pero distinto, y merece la pena conocerlas para que confíes en el decompilador donde es fiel y leas con cuidado donde parafrasea:

  • Un caso null se maneja antes de que el hash se calcule siquiera —no puedes hacer hash de null—, así que aparece como un brfalse al caso null al principio de todo, por delante de la llamada a ComputeStringHash. Glass lo pliega de vuelta en case null:, pero en el IL en crudo queda curiosamente lejos del resto del switch.
  • Una colisión de hash entre dos literales de caso reales produce dos comprobaciones op_Equality anidadas bajo una ramificación por hash; un decompilador puede mostrar esa cubeta como ifs anidados en lugar de dos case planos. Mismo comportamiento, forma ligeramente distinta.
  • goto case "x"; se compila a una ramificación de vuelta al despacho —a menudo directo al caso de la etapa 2 o a la asignación del índice de caso—, que un decompilador reconstruye como el goto case, pero solo si rastrea que el destino de la ramificación está dentro del mismo switch rebajado.
  • Un switch usado como expresión (la forma de expresión switch sobre constantes de cadena) se rebaja a través de la misma maquinaria de ComputeStringHash; la diferencia es solo si los casos producen un valor o ejecutan sentencias, lo cual es un paso de reconstrucción aparte.

La conclusión

Un switch sobre una cadena es uno de los pocos sitios donde una sola línea de C# se expande a una cantidad genuinamente sorprendente de IL: un ayudante de hash FNV-1a sintetizado, una ramificación por hash, una confirmación de igualdad ordinal por cada candidato y una tabla de salto entera densa —dos despachos con una comprobación de corrección encajada en medio—. El hash está ahí por velocidad, el op_Equality está ahí por corrección, y ninguno es información que quieras leer. El trabajo de un decompilador es reconocer la máquina entera, tirar los hashes, recuperar las literales de los operandos ldstr y devolverte el switch que escribiste. Esa es la diferencia entre leer switch (command) { case "build": ... } y entornar los ojos ante un muro de hexadecimal.

Véase también decompilar coincidencia de patrones y expresiones switch de C# para el switch de coincidencia de patrones que se rebaja mediante pruebas de tipo en vez de hashes, y cómo funcionan las tablas de metadatos de .NET para dónde viven realmente el tipo <PrivateImplementationDetails> y su ayudante inyectado. Prueba Glass.NET gratis en un ensamblado propio y mira cómo un switch sobre cadenas vuelve legible.

Prueba Nebula.NET

Endurece tu código .NET en minutos — empieza con la edición gratuita.