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.
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_Equalitybajo 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 suop_Equalityy cae adefault. - Un switch sobre cadenas es ordinal y distingue mayúsculas.
op_EqualitysobrestringesString.Equals—una comparación byte a byte, no una sensible a la cultura—."Build"no coincide concase "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:
- Una local asignada desde una llamada a un método llamado
ComputeStringHashen<PrivateImplementationDetails>cuyo cuerpo es el bucle FNV-1a. - Un despacho sobre esa local —una escalera de comparaciones o un
switchinterno— cuyos destinos cada uno realiza unop_Equalityde la cadena original contra una literalldstr. - Cada coincidencia confirmada asigna un índice denso y converge en un
switchfinal 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
nullse maneja antes de que el hash se calcule siquiera —no puedes hacer hash denull—, así que aparece como unbrfalseal caso null al principio de todo, por delante de la llamada aComputeStringHash. Glass lo pliega de vuelta encase 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_Equalityanidadas bajo una ramificación por hash; un decompilador puede mostrar esa cubeta comoifs anidados en lugar de doscaseplanos. 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 elgoto 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
switchsobre constantes de cadena) se rebaja a través de la misma maquinaria deComputeStringHash; 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.