martes, 17 de junio de 2014

Corrigiendo un ejecutable sin reiniciar la depuración

En este post les vengo a mostrar una funcionalidad nueva en ZinjaI, que verán en la próxima versión (a publicarse dentro de pocos días), y que bien presentada parece mágica, aunque está  lejos de serlo. Con el ejemplo adecuado, puedo hacer que se parezca al maravilloso Edit & Continue de Visual Studio (la funcionalidad que realmente me maravilla y envidio de ese IDE que tan poco conozco). Consiste en la posibilidad de modificar el ejecutable mientras se está ejecutando, durante una pausa en una depuración, alterando el código fuente, recompilando, y continuando luego de la pausa desde el mismo punto con el binario nuevo como si nada. Estoy a años luz de ofrecer algo como eso de verdad, pero puedo hacer algunos trucos simples para emularlo en casos muy muy particulares. Vean el video y luego sigan leyendo.


Cambiar un ejecutable por otro no es tan simple como cambiar la imagen que hay en memoria de un binario por la de otro. El ejecutable viejo tenía por ejemplo muchas variables estáticas en su imagen binaria, con direcciones prefijadas por el compilador, que pueden haber cambiado drásticamente en el nuevo. El ejecutable además tenía una pila de llamadas a funciones, donde los datos que se guardan en cada frame de esa pila dependen de la cantidad de argumentos de cada función, de su tipo, del tipo de pasaje que se utilice, de las variables locales que requiera cada una etc. Nuevamente, esto puede cambiar mucho, especialmente cuando el compilador hace optimizaciones. Las mismas funciones si cambian su código cambian de tamaño y por ello también potencialmente de posición relativa en el ejecutable. Como estas hay mil cosas que pueden hacer que el estado actual de un ejecutable simplemente no sirva para otro supuestamente "similar". Hay muchísima lógica acerca de cómo trabajan el compilador y el sistema operativo en conjunto, que el depurador debería conocer y controlar al detalle para poder hacer este tipo de magia.

Yo, como desarrollador de ZinjaI, en realidad desarrollo solo una interfaz para gcc y gdb, pero en general no se ni controlo cómo estos hacen lo que hacen. Implementar un verdadero Edit & Continue con gcc+gdb como base del toolchain implicaría meterse dentro de gcc, dentro de gdb, y tal vez hasta dentro del kernel de Linux para requerir cierta ayuda del sistema operativo. Por eso, desde ZinjaI no puedo ni soñar con hacer trucos tan complicados. Pero hay algunos casos especiales en los que sí se puede hacer de forma más o menos fácil sin alterar el toolchain. Supongamos por ejemplo que el error que queremos corregir está en el valor con que inicializamos una variable. Es probable que si lo corregimos y recompilamos el binario solo cambie en apenas unos pocos bytes, que son los que guardan ese valor. De igual forma pasa con el valor de un parámetro actual en la invocación de una función, o tal vez hasta si cambiamos la función que se invoca por otra con un prototipo compatible, o si invertimos la condición de un if, o algunos pocos casos más. Pero estos casos no son tan tan infrecuentes.

Considerando esto, lo que ZinjaI puede hacer, en una pausa durante una sesión de depuración, es lo siguiente: a) pedirle a gcc que recompile el binario; b) preguntarle a gdb cuales segmentos del binario original tenía cargados en memoria y dónde, c) copiar esa memoria en algunos archivos temporales; d) comparar esos temporales con las mismas secciones en el nuevo binario para identificar los cambios (esperando que no sean muchos); y e) pedirle a gdb que modifique las posiciones de memoria que cambiaron, con simples asignaciones de chars utilizando punteros, con el mismo mecanismo con el que gdb permite modificar las valores de las variables. Si los cambios no son muy drásticos, esto funciona. Aunque solo en GNU/Linux. Lamentablemente, el comando para pedir las secciones del binario cargadas en memoria no está implementado para Windows en gdb.

En conclusión, si bien funciona en casos acotados, no dejan de ser casos útiles, y una vez automatizado el proceso, es un punto de partida sobre el cual experimentar. Hay otras vías relativamente simples para aplicar parches en vivo que se podrían explorar, aunque combinarlas puede ser muy difícil. Por ejemplo, si comento una parte del código, al no incluirla en el ejecutable, la función compilada queda más corta y todo se reacomode diferente. Pero podría simplemente preguntarle a gdb dónde se mapean esas líneas en el binario viejo (cosa que ya sabe hacer fácilmente), y cambiar todos los bytes de esas instrucciones por la instrucción de ensamblador "nop", que hace nada ("no operation"), obteniendo el mismo comportamiento. Podríamos, con trucos de esta calaña, automatizar varios casos, aunque hay que ver si el esfuerzo de implementar todo eso compensa el poco uso que le podemos dar. Pero el primer caso que comenté requirió solo cambios mínimos en ZinjaI, ya que reutiliza muchas funciones desarrolladas para otras caracterísitcas del entorno, así que lo tendrán disponible para jugar ustedes también en la próxima versión (al menos para GNU/Linux).

No hay comentarios:

Publicar un comentario en la entrada