Spunti, suggerimenti e storie opinionate di vita vissuta
Penso di trovare il consenso di tutti dicendo che il debugging è una necessità. Penso anche di non offendere nessuno dicendo che debuggare è più un’arte che una scienza. Un’arte molto volubile, considerando che debuggare dipende tantissimo dal contesto in cui si sta lavorando. Ad esempio:
- che tipo di bug stiamo osservando? Il bug in questione porta ad un crash del programma? Il programma non termina perché si entra in un loop infinito? Oppure “semplicemente” il bug porta ad avere un valore di output sbagliato?
- quanto è riproducibile il bug? È completamente deterministico ed ogni esecuzione del programma triggera il bug? Oppure è un bug che si manifesta solo quando Marte è in opposizione alla Luna (e solo nei giorni dispari)? Oppure “semplicemente” è un bug che ha necessità di un ambiente configurato in una specifica maniera?
- che tipo di programma è in esame? Stiamo parlando di un programma single-run (come per esempio `grep`) che termina in una manciata di secondi? Oppure il bug è insito in una applicazione tipo keep-alive server la cui vita è molto lunga ed ha diversi entrypoint? Il flusso esecutivo è multi-threadeded con condivisione di risorse?
- quanto è alta la nostra confidenza nella base di codice? È un programma che contiene tantissimo codice legacy il cui funzionamento è per lo più tramandato per via orale? È codice recente scritto di nostro pugno? Il codice gode di buona salute oppure è tenuto insieme con lo scotch e le preghiere?
Questi sono solo alcuni dei vari contesti che mi vengono in mente, forse i più comuni. Ma anche senza andare ad elencarli tutti, provate a pensare: cos’è un bug? Per me una definizione abbastanza generica potrebbe essere “un’anomalia nel comportamento atteso dal programma”… ossia il “capitolo bug” è grande tanto quanto il “capitolo programma”. E di rimando, il “capitolo debugging” non sarà molto più piccolo.
Ma quindi, perché provare a fare un elenco dei vari tipi di bug e poi uscirsene a dire che il capitolo (de)bug è immenso? Solo per avvalorare questi due punti:
- ogni sessione di debugging è un mondo a parte: le tecniche di debugging più efficaci dipendono esageratamente dallo scenario in questione
- e conseguentemente, l’amara realtà che non esiste una soluzione magica che vada bene per tutti: anche la tecnica più banale può avere risultati spettacolari se usata nel giusto contesto!
Ed eccoci quindi arrivati al cuore di questo post (spero di avervi convinti): quali sono le tecniche (e gli strumenti) che possiamo utilizzare per fare debugging? Quali sono i loro pregi e difetti? In quali contesti eccellono?
Dopo quest’ultimo paragrafo altisonante, metto subito le mani avanti e ridimensiono la faccenda: fortunatamente, di strumenti e tecniche per aiutare il debugging ce ne sono tantissimi. Ci sono strumenti molto comuni che diamo per scontati, come per esempio la classica “print” dei linguaggi di programmazione. Ci sono quelli meno comuni che sono estremamente specifici per un particolare tipo di problemi, come per esempio wireshark. E poi ci sono quelli che “non esistono”, o meglio, che esistono solo per un brevissimo lasso di tempo (normalmente il tempo di scovare il bug) e risiedono temporaneamente nell’intimità dell’hard disk di un programmatore, spesso nella forma di scripts/appunti/parsers o simili.
In questo articolo tratterò di una piccolissima parte di questi strumenti e tecniche, per lo più rimanendo nel capitolo “strumenti molto comuni”.
Strumento 1: print
Le print… Penso siano il primo strumento che ogni programmatore usa per avere un po’ di introspezione nel proprio codice. E forse perché sono legate ai primi passi che un programmatore muove nel vasto mondo dell’informatica, le “print” vengono spesso derise ed etichettate come approccio ingenuo e da dilettanti quando è necessario debuggare. Che sia vero o meno, che faccia male all’orgoglio nerd doversi “abbassare ad usare le print”, fatto sta che usarle per debuggare è una tecnica potenzialmente a basso costo ed immensi benefici!
È potenzialmente a basso costo perché aggiungere uno statement di print non è difficile. Però, come ci insegna la vita di tutti i giorni, non possiamo fare i conti senza l’oste! Ad esempio il linguaggio di programmazione utilizzato potrebbe rovinarci la festa: potrebbe esserci la necessità di “ricompilare tutto”…e se il progetto è grosso, la compilazione potrebbe richiedere diverso tempo. Oppure: aggiungere la “print giusta” al primo colpo è certamente veloce…ma quante sono le possibilità che la prima print sia quella giusta? Questo approccio è un processo inerentemente iterativo, e questa iteratività richiede il suo tempo.
Gli immensi benefici? Se si conosce bene la base di codice, si possono inserire delle print in punti strategici che permettono di evidenziare velocemente e palesemente il bug.
Infine, un’altra cosa che viene spesso sottovalutata: le print prese e salvate su file appiattiscono il tempo del programma: ci fanno vedere senza la necessità di ri-eseguire l’intero programma il passato, il presente e il futuro (lasciatemi l’espressione, futuro inteso come “le print successive a quella su cui mi sto focalizzando ora al tempo T e su cui penso si annidi il bug”). Questo appiattimento ci permette di fare ragionamenti su come sta eseguendo il programma vedendo la rappresentazione del suo stato come tante piccole istantanee in evoluzione. Inoltre, le print salvate su file si prestano benissimo ad un post processing, che può essere utilizzato per capire quando è successo qualcosa di male, o quante volte accade una certa situazione… insomma, creare script ad-hoc che aiutano la disinfestazione!
Pros:
- non serve imparare nulla di nuovo: tutti sanno come scrivere una print;
- se si conosce bene il codice si riesce ad instrumentarlo velocemente e localizzare il bug in poche iterazioni;
- si prestano molto bene ad un post processing automatizzato o “cerebrale” (aka guardare a occhio l’evoluzione dei punti salienti del programma per sentire da dove arriva l’odore di bruciato).
Cons:
- ricompilare (o deployare) il progetto può richiedere tanto tempo, e deve essere fatto ogni volta che viene aggiunta una print nuova;
- se si sta andando a caccia completamente alla cieca, possono essere necessarie tante iterazioni trial-and-error prima di avvicinarsi al bug vero e proprio;
- problemi relativi a concorrenza e parallelismo sono difficilmente individuabili in quanto la print richiederebbe un accesso mutualmente esclusivo all’output, e più print si aggiungono e più si diminuisce il parallelismo e di conseguenza si rischia di mascherare il problema in esame
- progetti legacy dove il processo di compilazione o deploy è perso nei meandri della storia (o dove l’ambiente di sviluppo o l’ambiente runtime finale è difficile da ripristinare in una nuova sandbox isolata) rendono molto ostico questo approccio.
Strumento 2: log e trace
Abilitare il logging o il tracing di un’applicazione tramite variabili d’ambiente (o opzioni da linea di comando) costa zero… Le difficoltà maggiori sono sapere della loro esistenza e sapere come poi interpretare il risultato!
Log e trace: ma non sono la stessa cosa delle print? Effettivamente, il logging altro non è che “una selezione delle print più belle e utili che sono rimaste nel programma che è andato in produzione”. L’aspetto negativo (e che differenzia il log dalle print inserite manualmente alla bisogna) è che il log tende ad essere più generico e con una risoluzione più grossolana: in fondo, il log non sta andando attivamente a caccia di bug e le informazioni che ci darà difficilmente saranno tanto esaustive da permetterci di mettere all’angolo il bug. Però, i log e i trace hanno un vantaggio non indifferente: sono il distillato di esperienze e necessità passate, e come tali possono contenere tanti spunti utilissimi per indirizzarci sulla giusta strada. Quanto è bello vedere la stampa di log “WARNING: cannot connect to service XYZ, using stub instead” quando qualcosa non sta funzionando? Qualcuno ci sta già dicendo che qualcosa puzza in una precisa zona di codice… e tutto questo senza dover ricompilare nulla!
Un esempio di vita vissuta: logging/tracing di Qt. Esistono delle variabili d’ambiente e/o file di configurazione standard che permettono di modificare il livello di logging dell’applicazione (qualsiasi applicazione che contiene Qt!). Molto utile per scremare i problemi (senza dover ricompilare tutto Qt per aggiungere i simboli di debug oppure 2-3 print specifiche in un punto di codice), però come scrivevo poco sopra, ricordarsi che esistono e ricordarsi cosa fare esattamente per abilitarli è lo scoglio maggiore da superare!
Pros:
- non è necessario ricompilare o ri-deployare il programma, solitamente basta una variabile d’ambiente;
- qualche buon’anima passata ci sta tramandando informazioni su punti deboli/cose su cui prestare attenzione.
Cons:
- intrinsecamente generiche e non specifiche per il bug in questione;
- bisogna sapere che esistono e scoprire come fare ad abilitarle.
Strumento 3: opzioni di compilazione
Nel caso ci si stia cimentando con un programma che richiede la compilazione, è probabile che il compilatore offra la possibilità di aggiungere automaticamente del codice che faciliti il contenimento di errori logici (come race conditions, array out of bound, etc), dove con contenimento solitamente si intende “il sollevamento di un errore che blocca l’avanzata del programma nel momento in cui si riscontra l’anomalia logica” (e dove molto spesso questa anomalia logica tende ad essere la causa primordiale di un bug che diventa palese più tardi, e qui la scommessa è che questo “generico bug” sia in realtà il bug in esame e non un altro che è ancora latente nel codice).
Provo a fare qualche nome che dovrebbe essere già noto agli sviluppatori C/C++ (come abilitare queste opzioni dipende dal compilatore specifico):
- address sanitizer (anche conosciuto come ASan): utile per scovare problemi causati da accessi a memoria invalida (array out of bound, puntatori invalidi), double free, stack overflow solo per citarne alcuni;
- thread sanitizer (anche conosciuto come TSan): un amico preziosissimo quando c’è di mezzo concorrenza, visto che riesce a notificarci tempestivamente l’utilizzo invalido di memoria condivisa tra più threads;
- standard library versione di debug: caso specifico per C++ usando gcc. È possibile utilizzare una standard library leggermente più lenta, ma con molti più controlli di consistenza, che permettono di evidenziare potenziali “undefined behavior”, i quali rendono il codice non portabile o molto dipendente dagli input utilizzati. Nello specifico, è sufficiente ricompilare il programma usando `-D_GLIBCXX_DEBUG`.
Nonostante la specificità di questi strumenti, penso sia utile menzionarli perché molto spesso vengono dimenticati in quanto “opzioni non standard” o “di nicchia”.
Pros:
- controlli di consistenza con zero falsi positivi;
- possibilità di evidenziare l’errore non appena si esercita un comportamento anomalo del programma (che solitamente è la causa primordiale dei bug a cui si dà la caccia).
Cons:
- necessario ricompilare l’intera applicazione;
- specifici solo per alcuni linguaggi o per specifici compilatori.
Strumento 4: IDE con debugger integrato
Codice, esecuzione e breakpoints in un solo ambiente unificato con l’ergonomia del proprio IDE di fiducia: una goduria.
Onestamente non saprei che altro dire, è la combo perfetta per la gran maggioranza dei casi in cui si vuole veramente indagare passo passo quello che sta succedendo all’interno del nostro programma. Avere una corrispondenza 1:1 del codice sorgente con l’esecuzione del programma (e magari avere anche un po’ di pop up che compaiono, soffermandosi sulle varie variabili scritte direttamente nell’editor per vedere qual è il loro valore attuale) è un aiuto impareggiabile.
Aspetti negativi o limitazioni? Per esperienza personale, ritengo che lo scoglio principale sia l’ergonomia dell’IDE. Penso che le 3 cose più care ad un programmatore siano la propria tastiera, la propria sedia e il proprio editor di fiducia… Imporre l’utilizzo di un IDE perchè ha un debugger integrato chiedendo di tralasciare il proprio editor di fiducia: sì, si può fare una o due volte, ma se l’IDE non è di proprio gradimento è difficile averlo pronto a portata di mano quando serve. Inoltre, può capitare che per progetti particolarmente “fantasiosi e articolati” sia complicato configurare correttamente l’intero ambiente di sviluppo ed esecuzione (penso ad esempio una rete di microservizi, server docker da avviare a runtime, un progetto che utilizza più linguaggi di programmazione differenti).
Forse l’aspetto più limitante nell’utilizzare un debugger integrato in un IDE è la fatica (se non impossibilità) nell’automatizzare gli step di debug… in fin dei conti gli IDE sono per lo più programmi GUI, dove il mouse regna sovrano, mentre l’automazione è dominio di scripts dove la tastiera la fa da padrona.
Pros:
- un unico ambiente in cui scrivere codice, eseguire e debuggare;
- è molto semplice seguire il flusso esecutivo del programma;
- è molto semplice visualizzare i valori delle variabili a runtime.
Cons:
- non scriptabile;
- di difficile configurazione per progetti complessi.
Strumento 5: GDB
E quando non si ha a propria disposizione il proprio IDE di fiducia (mai capitato di dover debuggare un programma in un server remoto il cui unico metodo d’accesso è fare 3 hops in ssh?), rimane sempre la possibilità di utilizzare i debugger stand-alone. In ambiente *nix il più famoso è GDB (GNU Project Debugger).
Possiamo descrivere GDB come uno spione invadente che mette a nudo lo stack, lo heap, i threads e più in generale il processo che si vuole osservare. Il suo raggio d’azione è veramente ampio: avendo pieno accesso al programma da debuggare è in grado di:
- attaccarsi a processi già in esecuzione
- osservare lo stato del programma
- modificare l’esecuzione del programma
- osservare cosa stava combinando il programma al momento del crash (assumendo di avere a disposizione il file di coredump)
Un’altra flessibilità offerta da GDB è quella di poter automatizzare (tramite scripting) la sessione di debug. Questa feature è impagabile nel caso di bug molto reticenti in cui si capisce perfettamente cosa sta andando storto, ma non si capisce come mai le modifiche apportate non fixino il problema: avere uno script per ri-eseguire con un solo click l’intera sessione di debug è un risparmio in termini di tempo e sanità mentale da non sottovalutare!
Come per la maggior parte dei debugger, GDB permette di interrompere il programma quando si raggiunge una certa istruzione, quando una variabile viene sovrascritta, oppure quando si entra per la settima volta all’interno di un ciclo while. E come per la maggior parte dei debugger, GDB offre diversi front-end grafici, anche se nella sua forma base si presenta come tool da linea di comando. Questa sua forma base è un’arma a doppio taglio: da una parte l’assenza di una GUI rende ostico l’utilizzo del programma per un neofita, dall’altra parte permette ad un utilizzatore esperto di concentrarsi al 100% sul bug invece di doversi preoccupare di dove sono nascoste le varie configurazioni che rendono GDB tanto potente e versatile (mai capitato di raggiungere l’opzione che si stava cercando nascosta dietro 3 menù a tendina diversi, 2 dialog modali e un combo box?).
Come fare per familiarizzare con GDB da linea di comando? Io consiglierei di utilizzarlo per eseguire il post-mortem di un programma crashato. Partendo dal file di coredump (che altro non è che l’istantanea del programma al momento del crash) è possibile curiosare per capire cosa è successo. Sapere dallo stack trace che siamo entrati in un if e poi abbiamo fatto una divisione per zero è utile, ma guardarsi attorno (tra memoria e variabili) e scoprire perché effettivamente siamo finiti in quel branch con il divisore a zero lo è anche di più.
Pros:
- controllo completo sul programma in esecuzione;
- scriptabile;
- altamente configurabile (es: possibilità di aggiungere una rappresentazione custom delle proprie strutture dati, debuggare processi remoti utilizzando solamente ssh).
Cons:
- curva di apprendimento molto ripida;
- necessario ricompilare per aggiungere i simboli di debug al proprio programma (in realtà non è necessario, ma senza simboli di debug la vita si fa molto difficile).
Guarda anche il webinar si Luca su GDB:
Altri strumenti degni di nota
Mi permetto di menzionare una manciata di altri programmi che sanno rendersi molto utili in certi scenari
- valgrind: mettete in conto che l’esecuzione del vostro programma sarà molto più lenta del normale, ma per poter utilizzare valgrind non bisogna far altro che far eseguire il vostro programma da lui. A seconda delle varie opzioni fornite, è in grado di scovare tantissimi problemi legati alla (mal)gestione della memoria o dei thread. Ribadisco, tutto questo funziona con binding dinamici: non serve far nulla al vostro programma, nemmeno ricompilarlo!
- callgrind: già dal nome si nota una somiglianza con il sopracitato “valgrind”. Altro non è che una sua estensione. Perché citarlo? Perché tra tutti gli strumenti elencati finora non ne abbiamo menzionato nessuno che sia adatto a scovare “bug legati alle performances”. Callgrind in questo caso è uno tra i vostri più cari alleati poichè vi permetterà di individuare in maniera certa quali sono i bottleneck della vostra applicazione.
- wireshark: problemi legati allo scambio di dati su rete? non siete sicuri che i pacchetti del vostro nuovo protocollo che viaggiano nell’etere arrivino a destinazione? Wireshark permette di analizzare tutto il traffico dati che passa sotto le grinfie della vostra scheda di rete, compresi i pacchetti che vengono scartati.
- rr: una piccola menzione speciale a questo programma. Altro non è che GDB con la utilissima funzionalità di “poter andare indietro nel tempo”. Questa feature è impagabile, soprattutto per quei bug che sono flaky: basta riuscire a riprodurre il bug una sola volta, e poi si può ispezionare quella esecuzione tutte le volte che si vuole!
Conclusioni
Sono sicuro che ogni programmatore ha nel proprio intimo una storia da raccontare su come è riuscito ad ingegnarsi utilizzando uno strumento per scovare un bug. Con questo post ho cercato di dare qualche spunto e qualche accenno agli strumenti che mi sono capitati tra le mani durante i miei vagabondaggi nelle lande del codice sorgente. Se anche voi avete qualche storia da raccontare o un qualche strumento di debug a voi caro, non siate timidi: lì fuori c’è di sicuro un qualche programmatore ferito e smarrito che aspetta solamente di mettere a frutto la vostra esperienza passata!
Forse ti portrebbe interessare anche questo evento sul debugging del firmware.