Ladění a testování programů

[  Teoretické okénko  |  Ladění pomocí debuggeru (+NÁVOD)  |  Instrumentace programů  |  Assert - hledání invariantů programu  |  Valgrind - ladění programů s ukazateli  |  Profilování  ]

Teoretické okénko

Cílem programování je vytvořit funkční program, který splňuje všechny požadavky zadání a je odolný vůči různým způsobům používání1. První podmínkou pro splnění těchto cílů je dobrý návrh aplikace. Další podmínkou je znalost programovacího jazyka a třetí podmínkou je znalost prostředí v němž program poběží. Tímto přístupem se dají eliminovat ty nejzávažnější chyby aplikací. Pokud nejsou tyto tři podmínky splněny, nedá se laděním ani testováním moc zachránit.

Každý programátor dělá chyby. Je potřeba se s tím smířit, počítat s tím a systematicky se chyb zbavovat.

Co jsou a co nejsou programátorské chyby

V anglickém technickém slangu se chybě říká bug, což se dá přeložit jako brouk. Etymologie tohoto termínu je jistě zajímavá. Ostatně i v češtině říkáme, že "to má mouchy", když něco nefunguje. V programátorské praxi existuje několik druhů chyb a je potřeba si uvědomit, že ne za vše může programátor.

Programátor většinou nemá žádný vliv na hardwarové chyby. Existují ale i softwarové chyby, na které programátor nemá vliv. Příkladem může být například nedostatek volné paměti, kterou může operační systém přidělit našemu programu, nebo chyba v programu, se kterým náš program spolupracuje. Extrémním případem pak mohou být chyby v samotném operačním systému. Tyto chyby můžeme nazvat vnější chyby, tedy chyby, za něž programátor nenese přímou zodpovědnost.

S některými z těchto chyb ale může pomoci operační systém nebo ovladač tím, že umožní detekovat chybové kódy pro různé chybové stavy. Pokud to má smysl (velmi často to smysl opravdu má), měl by programátor tyto kódy testovat a ošetřovat. Čím větší škodu může chyba způsobit (program pro řízení jaderné elektrárny, projekt do školy), tím pečlivěji by si měl programátor počínat. Na druhou stranu u programů vytvářených pro vlastní potřebu asi lze být lehkovážnější. S lehkovážností to ale nelze přehánět, v zaměstnání (nebo ve škole) by se to nemuselo vyplatit.

Programátor se může dopustit syntaktických a sémantických chyb. Syntaktické chyby jsou prohřešky proti gramatice používaného programovacího jazyka, takže je při překladu odhalí překladač. Menší syntaktické chyby odhalí i některé chytřejší editory. Je tudíž dobré soubor pro kontrolu přeložit vždy po napsání nějakého kousku kódu (cyklus, tělo funkce). Pokud je projekt dlouhý, trvalo by to dlouho, proto je výhodné dělit programy na moduly a hlavně používat make. Některé editory (např. vim) umí program make spouštět, aniž byste je museli opustit. Vývojová prostředí typicky umožňují spustit překlad příkazem z menu nebo klávesovou zkratkou.

Sématické chyby jsou prohřešky proti sémantice, tedy proti požadovanému chování programu (sémantika = význam). Všechny tyto chyby principiálně nelze detekovat automaticky (stroji musíme nejprve nějak sdělit, co po něm chceme). Sémantické chyby způsobují chybnou funkci programu a právě na jejich odhalení se zaměřují všechny techniky hledání chyb, kterými se zde budu dále zabývat.

Chytání brouků

Sémantické chyby v programech lze odhalit laděním, testováním a verifikací. Nikdy nejde o plně automatické techniky, ale o pomůcky, které usnadní ruční hledání chyb. Nejlepším způsobem, jak se chyb zbavit, je vůbec je nedělat. K tomu jsou ovšem potřeba zkušenosti. Zkušenosti se hodí i při samotném hledání chyb. Dále se zde uplatní intuice, abstraktní myšlení, představivost, ale i štěstí. Pokud chcete být dobrým programátorem, či programátorkou, musíte trénovat a programovat a programovat...

Laděním máme obvykle na mysli systematické hledání chyb pomocí krokování v debuggeru. Debugger je program, který umožní zastavit běh programu po každém vykonání jednoho řádku programu nebo na předem definované zarážce (breakpoint). Pokud je laděný program pozastaven, lze si prohlížet či měnit obsah paměti či konkrétních proměnných, stav zásobníku, registrů a podobně. Tímto způsobem kontrolujeme, zda program skutečně dělá to, co jsme zamýšleli.

Testování je opět činnost jejímž primárním cílem je odhalit chyby. V tomto případě ovšem nesledujeme běh programu krok za krokem, ale snažíme se program spouštět za nejrůznějších (původně třeba nepředpokládaných) podmínek a sledujeme, jak se s nimi vypořádá. Testovat lze ručně nebo také pomocí různých skriptů a sofistikovaných testovacích nástrojů. V současné době existuje celé průmyslové odvětví, které se zabývá testováním aplikací.

Jakkoli náročným testováním nelze dokázat, že testovaná aplikace je zcela bez chyb. U některých programů však lze některé třídy chyb detekovat pomocí počítače, jako například zda se cyklus za určitých podmínek nestává nekonečným cyklem. Cíle formálně dokázat, že v programu nejsou určité třídy chyb, se snaží dosáhnout disciplína, která se nazývá formální verifikace. V této oblasti v současné době probíhá intenzivní výzkum. Velké firmy počínaje Microsoftem a konče Boeingem, či NASA za výzkum v této oblasti utrácí ročně obrovské sumy peněz. Zjednodušeně řečeno je princip formální verifikace takový, že matematicky popíšeme vlastnosti, které chceme v softwaru ověřit (například živost, dosažitelnost stavů, apod.) a pomocí počítače se snažíme dokázat, zda daná vlastnost platí či ne. Bližší vysvětlení však přesahuje rozsah toho, co zde chci říct (klíčová slova: formal verification, model checking).

Ladění pomocí debuggeru

Prvním pomocným programem, po kterém programátor chtivý odhalení chyb sáhne, je debugger. Debugger je program určený pro krokování programu a hledání chyb. Umožňuje sledovat běh programu řádek po řádku a sledovat při tom hodnoty proměnných, registrů nebo dokonce vybraných míst v paměti. Kvalitnější debuggery umožňují klást do programu zarážky (breakpoints) a přeskakovat tak kusy kódu, které nás zrovna nezajímají.

Debugger si lze představit jako simulátor instrukční sady procesoru, tedy jako program, který interpretuje instrukce programu namísto procesoru. Některé debuggery takto skutečně fungují a umožňuje to ladit programy pro jiné instrukční sady, než zná náš procesor.

Aby mohl debugger správně fungovat, je potřeba zdrojový kód překládat s ladícími informacemi. Jde o nadbytečné informace v kódu programu, které nemají vliv na jeho funkčnost. Debugger je ale umí využít pro správné zobrazení právě vykonávaného řádku zdrojovém kódu (umí si pak spojit binární kód s odpovídajícím řádkem ve zdrojovém souboru), hodnot používaných proměnných a ostatních potřebných informací.

Základním debuggerem dodávaným s GCC je řádkový gdb - GNU Debugger. Umí vše potřebné, ale dnešní programátory rozmlsané programy s grafickým rozhraním asi příliš nenadchne. O kvalitách tohoto debuggeru svědčí to, že většina grafických debuggerů pro GCC je pouhou grafickou nádstavbou nad gdb. To znamená, že gdb musí být nainstalován a tato GUI pak program gdb používají pro vykonávání všech operací, které nabízejí. Mezi nejpoužívanější patří DDD - Data Display DebuggerInsight.

Obecný postup při ladění pomocí debuggeru

Tento postup platí s malými odchylkami pro všechny dnešní debuggery (tedy i ty, které jsou součástí nějakého vývojového prostředí). Některé nabízejí i chytřejší funkce, ale základní princip používání je totožný.

  1. Přeložte program, který budete ladit s ladícími informacemi.
  2. Spusťte přeložený binární soubor pomocí debuggeru. U většiny debuggerů stačí binárku předat jako parametr debuggeru
    $ ddd ladenyprogram
    
    U integrovaných vývojových prostředí (IDE) toto odpadá, stačí zvolit příkaz Debug z menu. Pokud byl program přeložen s ladícími informacemi, debugger zobrazí zdrojový text programu, jinak většinou uvidíte kód v assembleru. Pokud je potřeba spouštět program s parametry příkazového řádku, u DDD lze tyto parametry použít už při spouštění.
    $ ddd ladenyprogram param1 param2
    
    Další možností je sdělit debuggeru parametry pomocí příkazu menu (v DDD je to Program/Run...).
  3. Umístěte do zdrojového textu zarážku (breakpoint). Na tomto místě debugger zastaví vykonávání programu a pak bude možné sledovat jeho stav (proměnné, paměť, registry, atd.). Existují dva typy zarážek
  4. Začněte s krokováním programu (většinou Debug/Run). Existuje několik módů krokování programu:
  5. Při zastavení na zvoleném řádku programu lze sledovat
  6. Novější debuggery umožňují měnit hodnoty proměnných. V dalším kroku pak bude program pracovat s těmito novými hodnotami. Tato funkce výrazně usnadňuje ladění, pokud je potřeba navodit specifický stav programu, do kterého se lze jinak dostat jen zdlouhavým výpočtem nebo interakcí s programem (např. když chcete otestovat, co udělá funkce, když jí místo ukazatele předáte null).
  7. Některé debuggery obsahují i editor, takže lze do kódu hned vkládat opravy. Nezapomeňte ale, že upravený program je potřeba před dalším laděním znovu přeložit. Například DDD poskytuje tlačítko Edit, kterým se spustí editor (většinou vim) a tlačítko Make, kterým se spustí příkaz make.

DDD

Původně jsem na tomto místě chtěl uveřejnit návod k použití s obrázky, který by krok za krokem vysvětlil, jak ladit jednoduché i složitější programy. Byla by to však zbytečná práce, protože na domácích stránkách tohoto projektu se nachází podrobnější a názornější návod, než jakého bych byl schopen já. Přečtěte si jej! Je tam na reálném programu názorně předvedeno, jak ladit a využít jeho schopnosti. Tento návod lze získat i jako PDF nebo Postscript. Pokud máte DDD nainstalován na svém počítači, najdete tyto návody v adresáři /usr/share/doc/ddd/.

Seznam důležitých odkazů:

Insight

Insight je jednoduchý, ale účinný debugger, který vytvořili vývojáři z RedHatu. Lze jej použít i v prostředí MS Windows, pokud používáte MinGW. Pokud pracujete ve Windows s DevC++, doporučuji tento debugger používat místo debuggeru zabudovaného v DevC++, který zatím nefunguje příliš dobře.

Seznam důležitých odkazů:

Instrumentace programů

Instrumentace programů je principiálně nejjednodušší technika ladění, kterou každý začátečník použije asi nejdříve (ale velmi, dokonce VELMI často ji používají i zkušení programátoři). Spočívá v doplnění zdrojového kódu programu o kontrolní výpisy.

int x = 10, y = 5;
printf("x = %d, y = %d\n", x, y); // kontrolní výpis
x = y++ - (x + 3);
printf("x = %d, y = %d\n", x, y); // kontrolní výpis
...

Princip je jednoduchý, ale překvapivě účinný. Pro ladění programů tímto způsobem není potřeba žádný debugger ani jiný dodatečný software. Na druhou stranu ovšem mohou existovat programy, které jsou schopny instrumentace vyhodnocovat a prezentovat získané údaje názorněji. Další výhodou kontrolních tisků je možnost analyzovat dlouhý běh programu nebo složité chování, jako například běh paralelního programu. V těchto případech je použití konvenčního debuggeru dost problematické.

Pokud si pro ladící výpisy vytvoříme vhodné makro, bude možné výpisy podle potřeby vypínat. V následující ukázce je pro vypínání či zapínání makra debug použit symbol NDEBUG, takže makro bude fungovat podobně jako assert. Příklad lze samozřejmě upravit a rozšířit, takže například vytvoříte různé skupiny maker pro výpisy, které půjdou vypnout podle potřeby (všechny/vybrané/žádné skupiny). Nebojte se experimentovat a vyzkoušejte si to.

#ifdef NDEBUG

#define debug(format, ...) {}

#else

#define debug(format, ...) \
  printf("%s, %s, %d: ", __FILE__, __func__, __LINE__); \
  printf(format, ## __VA_ARGS__)

#endif


// ukázka použití
int main(int argc, char **argv)
{
  debug("počet parametrů = %d", argc);
  ...
}

Poznámka: Makra s proměnným počtem parametrů, stejně jako proměnná __func__ a makro __VA_ARGS__ jsou dostupná jen v překladačích, které znají normu ISO C99. Pokud nevíte, co znamenají symboly __FILE__, či __LINE__, zkuste jejich význam najít na internetu nebo v manuálových či info stránkách.

Ačkoli instrumentace poskytuje množství výhod, nelze ji použít vždy. Nelze ji použít v situacích, kdy by kontrolní tisky samy měly vliv na funkčnost programu. Problémy může působit například u některých procesů komunikujících přes standardní výstup (zde ale lze tisknout na stderr). Někdy může působit problémy i to, že tisk na obrazovku výrazně zpomaluje běh programu, takže se některé zákeřné chyby projeví až v okamžiku, kdy jsou kontrolní výpisy vypnuty. Dá se ovšem říci, že v 95% programů instrumentace nebude činit žádné problémy a umožní najít množství chyb.

Assert - hledání invariantů programu

Makro assert neslouží přímo pro ladění programů. Jeho použití lze nazvat jako techniku sebeobranného programování. Hlavní myšlenka je jednoduchá - do zdrojového kódu programu umístíte na vhodná místa volání makra assert, jehož parametrem je logický výraz, o kterém jste přesvědčení, že bude za všech okolností pravdivý. Pokud během testování programu dojde k situaci, že se tento logický výraz vyhodnotí jako nepravdivý, znamená to, že váš předpoklad neplatí a v programu je chyba. Makro assert proto program ukončí a vypíše chybové hlášení o tom, na kterém řádku došlo k porušení předpokladu.

void prictiN(int pole[], int delka, int n)
{
  // zavolání funkce se záporným parametrem delka je programátorskou chybou
  assert(delka >= 0);

  for(int i = 0; i < delka; i++)
  {
    pole[i] += n;
  }
}

Definice makra assert se nachází v hlavičkovém souboru <assert.h>. Pokud jste si jistí, že program je dostatečně otestován, lze chování makra assert globálně vypnout tak, že před místem vložení hlavičkového souboru assert.h vložíte definici symbolu NDEBUG.

#define NDEBUG
#include <assert.h>

Potom se při překladu volání makra nahradí prázdným kódem.

Makro assert se velice často používá v programech s ukazateli a pro testování parametrů funkcí.

void writeList(const TList *list)
{
  assert(list != NULL);
  TItem *item = list->first;
  while (item != NULL)
  {
    printData(item->data);
    item = item->next;
    assert(item != list->first);
  }
}

POZOR! Makro assert nelze používat jako náhradu běžných testů, které jsou součástí funkčnosti programu. Je potřeba si uvědomit, že funkci makra assert lze vypnout. Pokud na vyhodnocení podmínky závisí funkčnost programu (vyhodnocení podmínky má například vedlejší efekt), je potřeba místo makra assert použít test pomocí příkazu if a v případě detekce chyby tisknout odpovídající chybové hlášení.

Makro assert se rozhodně naučte používat. Jeho správné používání vede ke kvalitnějším programům s menším počtem chyb. Kromě detekce chyb má makro assert ještě jeden pozitivní efekt. Nutí programátora přemýšlet nad vlastním kódem, což nikdy nemůže škodit.

Valgrind - ladění programů s ukazateli

Ukazatele a práce s pamětí alokovanou na hromadě způsobují jedny z nejčastějších a nejzávažnějších chyb v programech. Potíž s tou částí paměti, které říkáme hromada, je v tom, že tuto část paměti náš program sdílí s ostatními programy a o její využití musíme žádat operační systém (operační systém nám vytváří tuto abstrakci pohledu na paměť -- ve skutečnosti je to ještě trochu složitější). Pokud se náš program pohybuje v přidělené paměti, je všechno v pořádku. Problém nastává, když se nějakým omylem (např. indexováním mimo hranici pole nebo chybnou dereferencí) dostaneme mimo tuto bezpečnou zónu. Potom operační systém vyhodnotí chování našeho programu jako velmi nebezpečné a náš program je nemilosrdně ukončen. Tuto skutečnost pak systém ohlásí nechvalně známou zprávou "Segmentation fault". Operační systém tímto chrání sebe a ostatní programy. Pokud by to nedělal, mohly by programy číst nebo přepisovat paměť jiným běžícím programům a celý systém by se stal velmi nestabilním.

Tyto chyby jsou jedny z nejčastějších. Zároveň se tyto chyby obtížně hledají výše zmíněnými ladícími nástroji. Jedním z nástrojů, které v této situaci dokáží pomoci je program valgrind. Tento nástroj slouží pro dynamickou analýzu programů. To znamená, že zkoumá běžící programy. Princip jeho funkce je velmi blízký použití instrumentace, ale s tím rozdílem, že nevyžaduje od autora žádné speciální úpravy zdrojového textu.

Valgrind si můžete představit jako chytrý interpret instrukcí vašeho programu. Po spuštění řídí vykonávání jednotlivých příkazů (instrukcí) vašeho programu a zároveň si zaznamenává statistické údaje. Při použití pro náš účel, tedy ladění programů s ukazateli, si dělá statistiku příkazů, které pracují s dynamicky alokovanou pamětí (na hromadě). Po řádném skončení programu vytiskne přehlednou tabulku, v níž ukáže, kolik paměti a kolikrát jste celkově alokovali, kolik jste uvolnili a případně kolik paměti jste uvolnit zapomněli. Pokud program během vykonávání provedl nějakou podezřelou nebo chybnou operaci s pamětí nebo dokonce v důsledku takové chyby havaroval, vypíše vám valgrind informace o tom, na jakých řádcích se váš program choval špatně.

Jak tedy tento užitečný nástroj použít? Nejdříve jej musíte mít ve svém systému nainstalován. Valgrind funguje pouze na unixových systémech. Pokud používáte některou z Linuxových distribucí, určitě najdete instalační balíček v jejím repositáři balíčků. Dále před použitím přeložte svůj program a nezapomeňte použít přepínač překladače -g, který zapíná generování informací pro ladící nástroje. Následně se valgrind spouští z příkazového řádku:

vas_terminal$ valgrind --tool=memcheck -v --leak-check=yes --show-reachable=yes ./vasebinarka vasparametr1 vasparametr2

Parametrem --tool=memcheck se zapíná nástroj valgrindu, který kontroluje paměťové operace (tento parametr je možné většinou vynechat, protože se zapíná automaticky). Parametr -v zapíná více upovídaný mód. Pokud jej vynecháte, bude valgrind vypisovat informace jen v případě nalezení chyb a problémů. Parametr --leak-check=yes zapne výpis informací o paměťových únicích při skončení programu. Paměťovým únikem se nazývá situace, kdy zapomenete nějakou alokovanou paměť uvolnit nebo přijdete o ukazatel na ni, takže ji už nejste schopni uvolnit. S parametrem --show-reachable=yes bude valgrind kromě běžných paměťových úniků hledat i takzvané "nepřímé" paměťové úniky. To jsou situace, kdy používáte například struktury s ukazateli jako jsou binární stromy nebo lineární seznamy. Pokud přijdete o hlavní prvek takové struktury, hlavičku, je to přímý paměťový únik. Vy v tom okamžiku ale přijdete i o všechny prvky, na které je odkazováno z této ztracené struktury. To je nepřímý paměťový únik.

Pokud pracujete ve vašem programu s dynamicky alokovanou pamětí, velmi doporučuji valgrind používat hned od začátku psaní zdrojového kódu. Ideálně byste měli používat metodu takzvaného inkrementálního programování -- napíšete jednu funkci, a hned ji vyzkoušíte na vhodné testovací úloze pomocí valgrindu. Až odladíte chyby, napíšete další malou funkci, znovu odladíte a takto postupujete, dokud nedokončíte celý program. Pokud s testováním a valgrindem začnete až poté, co jste vytvořili celý program, můžete být zděšeni tím, kolik chyb vám valgrind odhalil. Obvykle pak strávíte opravami chyb dvakrát tolik času než samotným programováním.

Profilování

Profiler je program, který umí statisticky analyzovat, ve kterých funkcích program tráví nejvíce času. Tyto funkce pak má smysl optimalizovat. Je zbytečné ztrácet čas optimalizováním funkce, která se při běhu programu vyvolá jednou a program v ní stráví pár milisekund.

Vztah profilování k hledání chyb v aplikacích je nepřímý. Pokud program funguje podezřele pomalu, je zřejmé, že je někde chyba. Profiler může pomoci lokalizovat funkce, které nejvíce zdržují. Na tyto funkce pak můžeme zaměřit svou pozornost při hledání chyb. U malých programů je výhoda zanedbatelná, ale u větších aplikací, které se skládají z několika modulů a používají množství knihoven může být efektivní lokalizace chyb problémem. Profiler v těchto případech může pomoci.

Bližší informace najdete v samostatné kapitole o profilování.


1. Nemůžete spoléhat, že se uživatel bude chovat, jak si přejete. I když uživateli řeknete, že má zadávat pouze celá čísla bez znaménka, vždy se najde někdo, kdo zadá číslo -14ab. ;-)

Autor: David Martinek. Poslední modifikace: 29. October 2012. Pokud v tomto dokumentu narazíte na chybu, dejte mi prosím vědět.