Depurador de Rust: cómo mirar bajo el capó paso a paso
\n\n¿Alguna vez te has preguntado: «Depurador interactivo paso a paso para Rust: ¿cómo funciona bajo el capó?» Si has escrito en Rust y has intentado atrapar un error difícil, seguramente te has topado con GDB, LLDB o incluso con herramientas como rust-gdb. Pero, ¿cómo logran exactamente detener la ejecución del programa, mostrar los valores de las variables y avanzar paso a paso por el código fuente?
En este artículo no solo te contaremos cómo usar el depurador. Miraremos en su interior: desde el código máquina hasta los puntos de interrupción, desde los marcos de pila hasta los símbolos de depuración. Si te interesa no solo qué hacer, sino también por qué funciona, estás en el lugar correcto.
\n\n¿Por qué Rust es un caso especial para la depuración?
\n\nRust se compila a código nativo a través de LLVM. Esto significa que para la depuración usamos las mismas herramientas que para C/C++: GDB y LLDB. Pero Rust añade sus propias complejidades:
- \n
- Propiedad y préstamo: las variables pueden ser movidas, y el depurador debe rastrear esto. \n
- Monomorfización de genéricos: cada
Tse convierte en un tipo concreto, y los nombres de las funciones se vuelven «encriptados». \n - Optimizaciones: el compilador puede reordenar el código, y el depurador debe «desenredar» esto de vuelta. \n
Es por eso que el depurador interactivo paso a paso para Rust no es solo ejecutar un programa bajo GDB. Es todo un sistema que traduce las direcciones de máquina de vuelta a líneas de código fuente.
\n\n¿Cómo funciona un punto de interrupción (breakpoint) bajo el capó?
\n\nImagina: colocas un punto de interrupción en la línea println!("Hello");. ¿Qué sucede en el procesador?
Cuando ejecutas un programa bajo el depurador, este hace lo siguiente:
\n\n- \n
- Lee la información de depuración (formato DWARF) del archivo ejecutable. Allí se almacena la correspondencia: dirección en memoria → línea en el código. \n
- Reemplaza la instrucción en esa dirección por un comando especial
INT3(en x86 —0xCC). Esto es una «trampa» para el procesador. \n - Cuando la ejecución llega a este punto, el procesador genera una señal SIGTRAP. \n
- El depurador intercepta la señal, guarda el estado de los registros y te muestra la línea actual. \n
Aquí hay un ejemplo de un programa simple en Rust:
\n\nfn main() {\n let x = 42;\n let y = x + 10; // <-- punto de interrupción\n println!("y = {}", y);\n}\n\nCuando colocas un breakpoint en let y = x + 10;, el depurador encuentra la dirección de esta instrucción en el binario y reemplaza el primer byte con 0xCC. Después de la parada, restaura el byte original, ejecuta un paso y vuelve a colocar la trampa, si deseas continuar.
Step over, step into, step out: ¿cómo sabe el depurador dónde está la «siguiente línea»?
\n\nEsta es, quizás, la parte más mágica. ¿Cómo sabe el depurador que después de let y = x + 10; viene println!?
Todo está en las tablas de depuración. El compilador de Rust (a través de LLVM) genera la sección .debug_line en formato DWARF. Esta es una tabla comprimida que asigna direcciones de instrucciones a números de línea en el código fuente.
Por ejemplo, para nuestro código, la tabla podría verse así:
\n\nDirección 0x1000 → línea 2 (let x = 42;)\nDirección 0x1010 → línea 3 (let y = x + 10;)\nDirección 0x1020 → línea 4 (println!)\n\nCuando presionas step over, el depurador calcula la dirección de la siguiente instrucción que no pertenece a la línea actual y coloca un punto de interrupción temporal allí. La ejecución continúa hasta esa dirección, y pasas a la siguiente línea.
\n\nPara step into, el depurador verifica si en la dirección actual hay una llamada a función. Si es así, busca la primera línea de esa fun