Het debuggen van C/C++ applicaties


Bij het ontwikkelen van applicaties kom je verschillende malen voor fouten te staan die niet direct te verklaren zijn. Om de oorzaak van deze problemen vast te stellen zijn er verschillende tools zeer handig. Hier wordt het gebruik van enkele kort beschreven, naarmate er meer ervaring mee is kan deze HOWTO uiteraard worden uitbereid.

GDB


GDB staat voor Gnu DeBugger. Het is ontwikkeld door GNU en valt onder de GPL licentie en is dus vrij te gebruiken en zelfs een tool die niet mag ontbreken aan de ontwikkelomgeving van elk zichzelf respecterend developer. Het is een abnormaal uitgebreide debugger die voor zeer verscheidene programmeertalen kan worden ingezet, maar hier focussen we ons op C/C++ omgevingen.

GDB maakt gebruik van de gecompileerde versie van je programma. Het is echter aan te raden om bij het compileren al aan te geven dat je uit bent op het debuggen van het programma, zodat extra debugging informatie wordt toegevoegd aan de gecompileerde executable. De debugger-opties aanzetten gebeurt door bij het compileren de optie -ggdb mee te geven.

gcc -c Functions.c -ggdb
gcc -c Main.c -ggdb
gcc Functions.o Main.o -o Software -ggdb

Voor het compileren van enkele C-bestanden is het manueel toevoegen van deze optie snel gebeurd, maar indien het software-project groter wordt en er noodzaak is aan een Makefile kan het handig zijn om hier bij het aanmaken hiervan al in het achterhoofd te houden dat enkele extra opties gewenst kunnen zijn door hiervoor een variabele te voorzien. Een makefile die hier gebruik van maakt voor bovenstaande code kan er als volgt uitzien:
#Makefile voor Software-project
FLAGS = -ggdb
 
Software: Functions.o Main.o
gcc Functions.o Main.o -o Software $(FLAGS)
 
Functions.o: Functions.c Functions.h
gcc -c Functions.c $(FLAGS)
 
Main.o: Main.c
gcc -c Main.c $(FLAGS)
Na het compileren kan het programma worden geladen voor debugging om zo in de gdb-prompt te komen:
gdb Software

Het kan handig zijn om voordat het programma wordt uitgevoerd aan te geven waar er gestopt moet worden (breakpoints te zetten). Hier zijn verschillende mogelijkheden waarvan slechts enkelen (die meestal handig zijn) hier gegeven worden.
(gdb) break ''lijnnummer''
(gdb) break ''filenaam:lijnnummer''
(gdb) break ''functienaam''
(gdb) break ''lijnnummer:functienaam''
Deze opties wijzen zichzelf wel uit. De functies waarin de filenaam wordt meegegeven komen van pas indien je project uit meerdere files bestaat. Indien deze optie niet wordt meegegeven wordt er een breakpoint op de lijn/functie gezet van de file die laatst is weergegeven (wat met de optie "list" kan).

Na het zetten van breakpoints gaan we het programma uitvoeren met behulp van het commando "run"
(gdb) run
Hierdoor wordt het programma uitgevoerd en zal deze stoppen bij het eerste breakpoint of bij het genereren van een uitvoeringsfout (waaronder "segmentation fault" de meest gekende is). Nu kan je de inhoud van variabelen laten zien op een gelijkaardige manier dan dat dit in C gebeurd, met het commando "print"
(gdb) print ''variabelenaam''
(gdb) print ''arraynaam[3]''
(gdb) print ''geheugenadres''

Om het programma nu lijn per lijn te doorlopen gebruiken we het commando "step"
(gdb) step
Handig om hier te weten is dat gdb bij het uitvoeren van een "Enter" het laatst uitgevoerde commando nogmaals zal uitvoeren, zodat slechts 1-malig het step-commando dient te worden ingegeven en om dan verder te gaan een "Enter" volstaat.

Om de uitvoer van het programma verder te laten lopen kan het commando "continue" gebruikt worden.
Nu om de oorzaak van een segmentation-fault te vinden komt het commando "backtrace" (of "bt") goed van pas. Dit laat de stack zien.
(gdb) bt
De stack geeft weer welke functie er zijn opgeroepen, het is dan ook perfect mogelijk om te zien in welke functie de fout veroorzaakt werd en welke variabelen argumenten hierbij werden meegegeven (waarvan de inhoud dan weer met het print-commando kan worden weergegeven).

Valgrind


Valgrind is een tool die zeer handig is voor fouten die gerelateerd zijn aan dynamisch geheugen terug te vinden. Bij dynamisch geheugen is het namelijk zeer belangrijk dat elke byte geheugen de gereserveerd wordt ook terug veilig wordt vrijgegeven om geheugenlekken te voorkomen. Nu, de fouten kunnen veroorzaakt worden als er buiten het gereserveerde geheugen wordt geschreven waardoor de administratie van het dynamisch geheugen overschreven wordt. Hierdoor kan de oorzaak van het vastlopen van de software zijn oorzaak veel eerder in het programma hebben. Fouten die hierdoor veroorzaakt worden leiden meestal tot dergelijke fouten:

*** glibc detected *** malloc(): memory corruption (normal)
...

Ook bij valgrind is het gebruik van de -gdb parameter zeer handig. Hierdoor kan bij het vinden van fouten verwezen worden naar lijnnummers in de code. Hoe deze gebruikt worden is al aangehaald in het stuk van GDB.

Valgrind kan dan als volgt worden aangeroepen
valgrind --tool=memcheck ./Software
Hierdoor zal de software binnen valgrind worden uitgevoerd. Dit gaat een stuk trager dan de normale uitvoering gezien er constant wordt gekeken of het geheugengebruik legitiem gebeurt. Elke afwijking van legitiem gedrag wordt weergegeven. Er wordt ook een onderscheid gemaakt in het type fouten. Zo wordt gedetecteerd of geheugen meermaals wordt vrijgegeven, of het geheugen op een foutieve manier wordt vrijgegeven ("delete" voor geheugen dat met behulp van "malloc" is gereserveerd bijvoorbeeld), ... Wat een grote hulp vormt voor het oplossen van dergelijke fouten.

Aan het einde van de uitvoering wordt weergegeven hoeveel geheugen er definitief onbeschikbaar is geworden (en of er dus geheugenlekken zijn). In het beste geval wordt hier weergegeven dat al de geheugenblokken weer verwijderd zijn en er dus geen geheugenlekke mogelijk zijn.

Om specifiek op zoek te gaan naar de oorsprong van geheugenlekken gebruiken we het volgende commando
valgrind --leak-check=full ./Software
Hiermee wordt aangegeven welke functies de geheugenlekken veroorzaken. Hier worden de lijnnummers gegeven van de plaatsen waar geheugen wordt gereserveerd dat nooit meer wordt vrijgegeven, en zodoende onbeschikbaar zal zijn na afloop van het programma. Voor software die regelmatig zal worden uitgevoerd, iteratief zal worden aangeroepen of op platformen zal worden uitgevoerd met een beperkte hoeveelheid geheugen, is het ten zeerste aan te raden om ervoor te zorgen dat de applicatie volledig vrij te maken van foutief geheugen gebruik en geheugenlekken.

Het profilen van applicaties


Nadat de software is ontwikkeld en volledig is vrij gemaakt van al de mogelijke fouten, kan het handig zijn om een profiler te gebruiken om te onderzoeken hoe snel het programma loopt en waar zich de bottlenecks bevinden in de uitvoering ervan. De ervaring die ik hiermee heb is nog zeer beperkt, maar kan toch helpen bij het profilen van je eigen applicaties. Het profilen van je applicaties is van groot nut indien we uit zijn op versnelling om zo time-constraints te kunnen halen en onnodig moeite te steken in stukken code die amper enkele procent van de uitvoeringstijd in beslag nemen.

Valgrind, callgrind

De eerste is een tool die hierbij kan helpen is callgrind, welke met behulp van vallgrind kan worden aangeroepen.
valgrind --tool=callgrind ./Software
Ook nu zal de software binnen valgrind worden uitgevoerd, maar zal er na uitvoer een bestand zijn aangemaakt genaamt "callgrind.out.[PID]" met als PID de process-id van het uitgevoerde programma. Om de informatie hiervan te visualiseren kan gebruik worden gemaakt van kcachegrind.
kcachegrind callgrind.out.17324
waarbij 17324 de PID is van het uitgevoerde programma.

In deze grafische weergave is het eenvoudig de uitvoeringstijden van de verschillende programma's te vergelijken. Zo staat er links in een tabel het percentage van de uitvoeringstijd in deze functie wordt doorgebracht, de uitvoeringstijd van een enkele doorloping en het aantal uitvoeringen dat heeft plaats gevonden. Uiteraard moet hier rekening worden gehouden dat de uitvoeringstijd van een functie afhankelijk is van de functies die hierin worden opgeroepen.

Om dit te onderzoeken is het tabblad "Source Code" (aan de rechterkant bovenaan) handig. Hierin wordt de broncode van de functie weergegeven, en door de optie "Relative to parent" aan te klikken wordt hier in het blauw het percentage weergegeven binnen de uitvoering van deze functie (zonder deze optie is dit het percetage van de totale uitvoeringstijd).

De "Callee map" geeft hier een andere voorstelling van. Hier wordt een grafische voorstelling gemaakt van het aandeel dat de opgeroepen functies hebben in de uitvoeringstijd door rechthoeken die naast elkaar (verschillende functies die afzonderlijk worden opgeroepen) of binnen elkaar (functies die binnen een andere functie worden opgeroepen) staan. Ook hier kan weer de optie "Relative to Parent" al dan niet worden gebruikt.

De mooiste weergave is naar mijn mening echter de "Call Graph" welke rechts onderaan kan worden geselecteerd. Hierbij is een zeer mooi gestructureerd overzicht gegeven van welke functies welke oproepen en hun aandeel in de uitvoeringstijd. Dit levert echter geen extra informatie op boven de andere weergave en is dus tenslotte maar een andere visualisatie-methode.

gprof


Een andere profiling tool is gprof. Hier zijn zeer gelijkende resultaten mee te boeken als met valgrind. Voor het gebruik van grof dient er echter een extra optie te worden gegeven bij het compileren, welke eenvoudig met de FLAGS variabelen kan worden toegevoegd die we in onze Makefile hebben voorzien.
FLAGS = -pg
Na het compileren dient het programma op de normale wijze worden uitgevoerd
./Software
Na het uitvoeren zal er een extra bestand zijn aangemaakt genaamd "gmon.out" (mits compilatie van de -pg optie).
Om de profiling informatie weer te geven voeren we het volgende commando uit
gprof ./Software
welke al de profiling informatie naar het scherm zal schrijven. Het kan dus handig zijn deze output direct in een bestand weg te schrijven
gprof ./Software > ProfileInformatieSoftware.txt
Ook hier kan een visualisatie van worden gegeven met behulp van kprof
kprof
Welke dan de uitvoer van gprof kan lezen. Ook hier worden zeer gelijkend resultaten weergegeven als we bij de valgrind-profiler zagen, zoals uitvoertijd binnen de functie, percetage van de totale uitvoertijd, hoeveel tijd een enkele functiedoorloping nodig heeft, hoeveel keer de functie wordt aangeroepen, ...

Ook hier is het mogelijk om deze informatie in een boomstructuur weer te geven, al is het veel moeilijker hier de structuur van de applicatie en de volgorde van functie-oproepen uit te halen. Dit kan uiteraard te wijten zijn aan de gebrekkige ervaring met deze profiler-tool.