Grafos de flujo de control — dominadores, bucles e irreducibilidad
El grafo de flujo de control es la estructura sobre la que se apoya todo lo demás. Este capítulo la construye, define la relación de dominancia —de la que dependen tanto la forma SSA como la reconstrucción de estructuras de control— y estudia los bucles, incluido el caso incómodo de los grafos irreducibles, que en binarios reales son mucho más frecuentes de lo que la literatura de compiladores sugiere.
4.1 Bloques básicos y grafo de flujo de control
Un bloque básico (basic block) es una secuencia máxima de instrucciones tal que el control entra por la primera y sale por la última, sin saltos intermedios ni destinos de salto en su interior. La partición de un programa en bloques básicos se obtiene marcando como líder (leader) toda instrucción que sea la primera del programa, destino de un salto, o inmediatamente posterior a un salto; cada bloque va de un líder al siguiente exclusive.
El grafo de flujo de control (CFG) es el grafo dirigido cuyos nodos son los bloques básicos y cuyas aristas representan las transferencias de control posibles. Se supone siempre un único nodo de entrada, entry o r (por root), y un único nodo de salida, exit. Cuando el programa tiene varios return, se añade un nodo exit artificial con aristas desde todos ellos; cuando el grafo se recorre en sentido inverso —cosa que hacen los análisis backward— ese nodo pasa a ser la raíz.
Escribiremos pred(v) y succ(v) para los conjuntos de predecesores y sucesores de v. Un nodo con |succ(v)| > 1 es un nodo de bifurcación; uno con |pred(v)| > 1 es un nodo de confluencia o join node, y son precisamente los nodos de confluencia los que hacen difícil el análisis: son los puntos donde hay que combinar información procedente de caminos distintos.
Éste es el CFG que usaremos como ejemplo en este capítulo y, sobre todo, en el capítulo «13», donde se le construye la forma SSA:
+-------+
| r | entry
+---+---+
|
v
+----->+-------+
| | A |
| +---+---+
| / \
| v v
| +-------+ +----------+
| | B | | C |
| | y <- 0| | tmp <- x |
| | x <- 0| | x <- y |
| +---+---+ | y <- tmp |
| | +----+-----+
| | | \
| v v \
| +---------------+ |
| | D | |
| | x <- f(x, y) | |
| +-------+-------+ |
| | \ |
+----------+ \ |
v v
+---------------+
| E |
| ret x |
+---------------+
Es decir: A → B, A → C, B → D, C → D, C → E, D → E y la arista de retroceso D → A.
4.2 Del binario al CFG
En un compilador, construir el CFG es trivial: la estructura sintáctica lo dicta. En un binario es un problema abierto, por el círculo vicioso ya señalado en el capítulo «1». El procedimiento real es un punto fijo:
build_cfg(entries):
worklist := entries
cfg := empty
while worklist not empty do
a := pop(worklist)
decode basic block starting at a, add to cfg
for each successor s of the block:
if s is a known address and s not in cfg then push s
if the block ends in an indirect branch then
T := value_analysis(cfg, branch operand) // capitulo 8
if T is bounded then
for each t in T: if t not in cfg then push t
else
mark block as unresolved
return cfg
Cada vuelta del bucle amplía el grafo, lo que mejora el análisis de valores, lo que puede resolver más saltos indirectos, lo que vuelve a ampliar el grafo. Se termina cuando ninguna iteración añade nada.
Los bloques marcados como unresolved son la deuda técnica del análisis. Un CFG con saltos indirectos sin resolver es incompleto, y todo análisis posterior debe tratarlo de forma conservadora: suponer que el salto puede ir a cualquier parte, lo cual destruye casi toda la precisión, o suponer que va a los destinos conocidos, lo cual es incorrecto. Las herramientas reales eligen lo segundo y no siempre lo advierten.
4.3 Dominancia
Sea G un CFG con raíz r.
- El nodo
ddomina al nodon, escritod dom n, si todo camino desderhastanpasa pord. ddomina estrictamente ansid dom nyd ≠ n.- El dominador inmediato de
n,idom(n), es el dominador estricto denmás próximo an; es decir, el únicodque domina estrictamente any que no domina estrictamente a ningún otro dominador estricto den.
La relación de dominancia es reflexiva, transitiva y antisimétrica, y idom induce un árbol —el árbol de dominadores (dominator tree)— con raíz en r, en el que el padre de n es idom(n). Para el CFG de la «sección 4.1 · Bloques básicos y grafo de flujo de control»:
r
|
A
/ | \
B C \
\
D-------E
idom(A)=r idom(B)=A idom(C)=A idom(D)=A idom(E)=A
En efecto: A domina a todos salvo a r, pero ni B, ni C, ni D dominan a E, porque a E se puede llegar por r → A → C → E, que evita B y D, y por r → A → B → D → E, que evita C. El dominador inmediato de los cuatro es A.
4.4 Postdominancia y dependencia de control
Aplicando la definición de dominancia al grafo con las aristas invertidas y raíz en exit se obtiene la postdominancia: p postdom n si todo camino desde n hasta exit pasa por p.
Con las dos relaciones se define la dependencia de control (control dependence), que responde a la pregunta «¿de qué decisión depende que esta instrucción se ejecute?». Un nodo n es control-dependiente de b si:
- existe un camino de
banen el que todo nodo intermedio es postdominado porn, y bno es postdominado porn.
Intuitivamente: b es una bifurcación en la que una rama lleva necesariamente a n y la otra no. El grafo de dependencias de control (control dependence graph, CDG) recoge esta relación y, combinado con el grafo de dependencias de datos, forma el grafo de dependencias del programa (program dependence graph, PDG), que reaparece en el capítulo «16».
4.5 Conjuntos de confluencia y fronteras de dominancia
Estos dos conceptos son la clave del algoritmo de construcción de SSA, y conviene entenderlos aquí, con independencia de SSA.
Dado un conjunto de nodos S de un CFG, el conjunto de confluencia (join set) J(S) es el conjunto de nodos del CFG a los que se puede llegar desde dos o más elementos distintos de S mediante caminos disjuntos.
En el CFG de la «sección 4.1 · Bloques básicos y grafo de flujo de control»:
J({B, C}) = {D}, porque se puede ir deBaDy deCaDpor caminos distintos y sin solaparse.J({r, A, B, C, D, E}) = {A, D, E}, porqueA,DyEson los únicos nodos con más de un predecesor.
La frontera de dominancia (dominance frontier) de un nodo n, escrita DF(n), es el borde de la región del CFG dominada por n. Formalmente, DF(n) contiene todos los nodos x tales que n domina a un predecesor de x pero n no domina estrictamente a x.
En el ejemplo: la frontera de dominancia del bloque B es {D}. La de C es {D, E}. La de D es {A, E} —A por la arista de retroceso D → A, y E porque D no domina estrictamente a E.
DF se define sobre nodos individuales, pero se extiende a conjuntos por unión: DF(S) = ⋃s ∈ S DF(s). La frontera de dominancia iterada DF⁺(S) se obtiene iterando el cálculo hasta alcanzar un punto fijo:
Y DF⁺(S) es el límite de esa sucesión.
La relación con los conjuntos de confluencia es exacta y merece recordarse:
Es decir, la frontera de dominancia iterada es el conjunto de confluencia suponiendo una definición implícita en el nodo raíz. Es una sobreaproximación de J(S), y es la razón de que el algoritmo clásico de construcción de SSA inserte a veces funciones φ que no hacían falta.
Para el CFG del ejemplo, si las definiciones de x están en {B, C, D}, entonces DF⁺({B, C, D}) = {A, D, E}, y no hace falta iterar: la primera aplicación de DF ya da el resultado.
4.6 Bucles
Una arista de retroceso (back edge) es una arista u → v tal que v dom u. El bucle natural (natural loop) asociado a la arista de retroceso u → v es el conjunto formado por v —la cabecera (header) del bucle— más todos los nodos que pueden alcanzar u sin pasar por v.
En el ejemplo, D → A es arista de retroceso porque A dom D, y el bucle natural es {A, B, C, D}.
Dos bucles naturales distintos o son disjuntos, o uno está contenido en el otro, o comparten cabecera —en cuyo caso se suelen fusionar. Eso permite organizarlos en un bosque de anidamiento de bucles (loop nesting forest): un bosque cuyos nodos son los bucles, con un bucle como hijo de otro si está anidado dentro.
Notación útil, que se usará en los capítulos «14» y «23»: para un nodo v, HLC(v) (headers of loops containing v) es el conjunto de cabeceras de los bucles que contienen a v.
4.7 Reducibilidad
Un CFG es reducible (reducible) si toda arista de retroceso u → v cumple v dom u; equivalentemente, si al eliminar las aristas de retroceso el grafo resultante es acíclico. Otra caracterización clásica, debida a Hecht y Ullman, es que el grafo se colapsa a un único nodo aplicando repetidamente dos transformaciones: T1, eliminar un bucle propio, y T2, fusionar un nodo con su único predecesor.
Intuitivamente, un grafo reducible es aquél en el que todo bucle tiene un único punto de entrada. Los programas escritos con las construcciones estructuradas habituales —while, for, do, if, switch, break, continue— producen siempre grafos reducibles. Por eso la literatura de compiladores trata la irreducibilidad como una curiosidad.
Un grafo irreducible tiene al menos un bucle con dos o más entradas:
+-------+
| entry |
+---+---+
/ \
v v
+-----+ +-----+
| u |<->| s |
+-----+ +-----+
Ni u domina a s ni s domina a u, luego ninguna de las dos aristas del ciclo es de retroceso según la definición, y no hay bucle natural.
Hay dos formas de tratar la irreducibilidad. La primera es evitarla: casi todos los algoritmos tienen una variante que funciona sobre grafos arbitrarios, normalmente a costa de más iteraciones. La segunda es eliminarla transformando el grafo mediante duplicación de nodos (node splitting): se replica el cuerpo del bucle una vez por cada entrada, lo que convierte cada entrada en la cabecera de un bucle natural distinto. El coste es que la duplicación puede ser exponencial en el peor caso, y por eso se usan variantes controladas que limitan el factor de expansión.
4.8 De vuelta al código fuente: estructuración
Reconstruir while y if a partir del CFG —la operación inversa de la «sección 3.5 · Árboles sintácticos y grafos de flujo de control»— se llama estructuración de control (control-flow structuring) y es la última fase de un decompilador. Hay tres generaciones de técnicas:
-
Análisis por intervalos y análisis estructural (Cifuentes, años noventa). Se busca en el CFG, de dentro hacia fuera, la aparición de esquemas conocidos —bucle
while, bucledo-while,if-then,if-then-else,switchde n vías— y cada coincidencia se colapsa en un nodo abstracto. Si al final queda un solo nodo, el programa era completamente estructurable; lo que no encaja en ningún esquema se emite comogoto. Es lo que hacen los decompiladores clásicos, y es la razón de que Hex-Rays produzcagotoen algunas funciones. -
Estructuración basada en condiciones semánticas (pattern-independent structuring, Yakdan et al., 2015). En vez de buscar esquemas topológicos, se calcula para cada nodo la condición de alcanzabilidad —una fórmula booleana sobre las condiciones de rama que describe cuándo se ejecuta ese nodo— y se genera el código a partir de esas fórmulas, simplificándolas con un solucionador. El resultado es código sin
gotoen la gran mayoría de funciones, a cambio de condiciones a veces más complejas. -
Enfoques híbridos actuales, que combinan lo anterior con duplicación controlada de nodos para tratar los casos irreducibles y con heurísticas de legibilidad.
Este es el punto en el que la teoría de este capítulo se convierte en el pseudocódigo que uno lee en pantalla, y el capítulo «24» lo retoma dentro del pipeline completo.
4.9 Lecturas
Las referencias sobre estructuración son Cifuentes (1994) para el análisis estructural y Yakdan et al. (2015) para la estructuración independiente de patrones; sobre reducibilidad, Hecht y Ullman (1972); sobre cálculo de dominadores, Lengauer y Tarjan (1979) y Cooper, Harvey y Kennedy (2001).