Cómo Glass reconstruye una instrucción switch de C# a partir de una tabla de saltos de IL
Un switch de C# sobre un rango denso de enteros no compila a una cadena de comparaciones: compila a un único opcode switch de IL que lleva una tabla de destinos de salto, indexada por el valor del switch. Esto recorre cómo es esa tabla de saltos, cómo el compilador normaliza el valor restando el caso mínimo, por qué los switch dispersos se convierten en cadenas if/else de búsqueda binaria, y cómo Glass.NET convierte la maraña de gotos resultante en un switch (x) { case 10: ... } limpio.
Una instrucción switch de C# parece, en el código fuente, una pulcra bifurcación de varias vías. Por dentro, sobre un rango denso de enteros, es algo más interesante: una única instrucción de IL (el opcode switch) que lleva una tabla ordenada de destinos de salto y despacha en tiempo constante usando el valor del switch como índice. No hay ninguna comparación. Eso la hace rápida, y convierte la salida de un decompilador ingenuo en un revoltijo de goto y etiquetas numeradas. Reconstruir el limpio switch (x) { case 10: ... } a partir de esa tabla es un problema de decompilación pequeño y autocontenido, y es una buena lente para ver qué hace realmente un decompilador.
Glass.NET es un decompilador de .NET gratuito e inspector de ensamblados con una interfaz Avalonia al estilo de Visual Studio y una herramienta de línea de comandos glass. Todo lo que sigue es análisis estático: lee los metadatos y los cuerpos de los métodos de un ensamblado que ya tienes y que tienes derecho a inspeccionar, y nunca carga ni ejecuta el objetivo.
El opcode switch es una tabla de saltos
Empecemos con un switch denso: los casos forman un rango de enteros contiguo:
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 "?";
}
}
Los cinco casos van de 10 a 14 sin huecos. Roslyn no emite cinco comparaciones. Emite un único opcode switch cuyo operando es una tabla de cinco destinos de salto, precedido por la aritmética que convierte score en un índice basado en cero:
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
Lee la línea switch con atención, porque es todo el mecanismo. El opcode saca un entero sin signo de la pila (aquí el índice normalizado) y lo trata como una posición en la lista de destinos. El índice 0 salta a IL_001F, el 1 a IL_0023, el 2 a IL_0027, y así sucesivamente. Si el índice es mayor o igual que 5 (el número de entradas), el opcode no hace nada y la ejecución cae a la instrucción inmediatamente siguiente, IL_001A, que es un br.s incondicional al cuerpo default en IL_0033. Una instrucción, despacho O(1) y una caída incorporada para default.
Normalizar el valor del switch
Los casos empiezan en 10, pero una tabla de saltos siempre se indexa desde cero. Ese desfase lo cierran las dos instrucciones anteriores al switch:
ldc.i4.s 10
sub
El compilador resta la etiqueta de caso mínima (10) a score. Ahora score == 10 se vuelve índice 0, score == 11 se vuelve índice 1, hasta score == 14 que se vuelve índice 4. La resta es también lo que hace que la caída maneje de una vez ambos extremos del rango default: si score es 9, la resta da -1, que como valor sin signo es 0xFFFFFFFF (enorme, desde luego >= 5), así que cae a default. Si score es 100, el resultado es 90, también >= 5, la misma caída. Una sola comparación sin signo, implícita en el opcode, cubre todo lo que queda fuera del rango en ambas direcciones.
Esta normalización es lo más importante que un decompilador debe acertar aquí. La tabla de saltos registra del índice 0 al 4, pero el código fuente nunca mencionó el 0; dijo case 10. Para reconstruir las etiquetas originales, el decompilador tiene que encontrar la constante restada y volver a sumarla: etiqueta de caso = índice de tabla + 10. Si se pierde el sub, cada caso reconstruido queda desplazado por el mínimo.
Lo que produce un decompilador ingenuo
Si un decompilador se detiene en el grafo de flujo de control sin reconocer el patrón, emitirá fielmente exactamente lo que dice el IL: una bifurcación calculada hacia etiquetas, con la normalización dejada como expresión aritmética. El resultado es correcto pero ilegible:
// salida ingenua, sin reconocer el patrón
int num = score - 10;
switch (num) // o peor: una cascada 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 "?";
El num = score - 10 deja al descubierto la normalización que el compilador añadió. Los casos están numerados 0..4 en lugar de 10..14. Y a los cuerpos se llega mediante goto hacia etiquetas en vez de estar dentro del switch. Compila y es correcto, pero no se parece en nada al código fuente.
Cómo recupera Glass el switch limpio
Glass.NET trata toda la secuencia (la carga, la resta, el opcode switch, el br de caída y los cuerpos de destino) como un único modismo reconocible, y revierte cada paso:
- Leer la tabla de saltos. El operando de
switches una lista ordenada de desplazamientos:IL_001F, IL_0023, IL_0027, IL_002B, IL_002F. El índiceicorresponde al cuerpo en el desplazamientoi. - Recuperar la normalización. Glass ve el
ldc.i4.s 10; subque alimenta el valor del switch y registra el mínimo restado como10. La etiqueta de caso para el índice de tablaies, por tanto,i + 10. - Emparejar el default. La instrucción inmediatamente posterior al
switches la caída (aquíbr.s IL_0033). Ese desplazamiento es el cuerpodefault. Glass también integra el comportamiento implícito fuera de rango endefaulten lugar de emitir una comprobación de límites explícita. - Adjuntar los cuerpos. Cada desplazamiento de destino se convierte en un
casecuyo cuerpo es el código lineal en ese desplazamiento, con elbrfinal hacia la salida común reconocido como el final del caso.
La salida reconstruida es exactamente la original:
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 "?";
}
}
Sin goto, sin el score - 10 filtrado, con los casos numerados desde 10 tal como se escribió.
Los switch dispersos no son tablas de saltos
La tabla de saltos solo compensa cuando los casos son densos. Sepáralos y una tabla sería en su mayoría ranuras vacías: un switch sobre 1, 1000 y 100000 necesitaría una tabla de cien mil entradas para alojar tres cuerpos. Así que el compilador cambia de estrategia. Considera:
public static string Classify(int code)
{
switch (code)
{
case 1: return "low";
case 1000: return "mid";
case 100000: return "high";
default: return "none";
}
}
Aquí Roslyn no emite ningún opcode switch. En su lugar construye un árbol de búsqueda binaria de comparaciones, probando primero el valor central para reducir la búsqueda a la mitad:
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
El bgt.un.s de arriba parte el rango, y las instrucciones beq.s prueban valores individuales. bgt.un (el mayor-que sin signo) es el mismo truco de antes: comparar sin signo permite que una sola bifurcación atrape también todo lo que está por debajo del caso, por eso ves .un incluso en switch sobre int aparentemente con signo. Para un conjunto disperso más grande el árbol se hace más profundo, pero la forma es siempre esta escalera de comparaciones, nunca la tabla de un solo opcode.
Glass.NET también reconstruye este en el idéntico switch (code) { case 1: ... case 1000: ... }. El trabajo es distinto (no hay tabla que leer ni mínimo que volver a sumar; en su lugar Glass tiene que reconocer el árbol de comparaciones de búsqueda binaria y colapsarlo de nuevo en etiquetas de caso) pero la salida es el switch de C# limpio en cualquier caso. Un switch del mundo real a menudo compila a un híbrido: los grupos densos de etiquetas se vuelven pequeñas tablas de saltos, y los valores aislados sueltos se vuelven comparaciones beq acopladas al árbol. Glass maneja la forma mixta reconstruyendo cada región con la estrategia correspondiente y fusionándolas bajo un único switch.
Probarlo con la CLI glass
Para ver cualquiera de esto por ti mismo, apunta la CLI al tipo. La vista de C# decompilado te da el switch reconstruido:
glass decompile MyApp.dll --type MyApp.Grading.Scorer
Para ver la maquinaria por debajo (el opcode switch con su lista de destinos, la normalización sub y el br de caída) cambia a la vista de IL para un solo método:
glass decompile MyApp.dll --type MyApp.Grading.Scorer --member Grade --il
Leer ambas una al lado de la otra es lo importante. El C# te muestra la forma que escribió el autor; el IL te muestra si el compilador eligió una tabla de saltos o una escalera de comparaciones para implementarla. Ambas son lecturas estáticas del mismo ensamblado (no se carga ni se ejecuta nada), lo que hace que esta sea una forma segura y legítima de entender y depurar código compilado que tienes derecho a inspeccionar, ya sea tu propia compilación o una biblioteca de terceros de la que dependes.
Conclusión
Un switch denso de C# no es una cadena de if: es una tabla de saltos despachada por un único opcode switch de IL, con el valor del switch normalizado restando el caso mínimo para poder indexar la tabla desde cero, y una caída implícita de rango sin signo que hace las veces de default. Un switch disperso es lo opuesto: una escalera de búsqueda binaria de comparaciones beq/bgt.un. Un decompilador ingenuo te muestra los gotos y la resta filtrada; Glass.NET lee la tabla, vuelve a sumar el mínimo restado, empareja el destino default y te devuelve el limpio switch (x) { case 10: ... } que el autor realmente escribió.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.