Debug vs. Release

Častým problémem, který se řeší na programátorských fórech, bývá "Mám projekt, který při zkompilování do verze Debug běží bez problémů, ale když jej zkompiluji do verze Release, nefunguje". Cílem tohoto článku je shrnout rozdíly mezi oběma verzemi programu

Proč dvě verze ? Jedna stačí, ne ?

Důvodem, proč vytvářet ladicí (Debug) a finální (Release) verze, je nutnost ladění programu. Při vývoji jakéhokoliv programu (kromě těch nejjednodušších, jako "Hello, world !!"), je nutné použití debuggeru pro hledání chyb. Bez něj je hledání chyb často nesmírně obtížné, a časově velmi náročné. K tomu, aby mohl být debugger použít, je nutné výsledný EXE (nebo DLL) soubor upravit. Nejdůležitější úpravou je připojení tzv. ladicích informací (Debug info) k programu. V ladicí verzi bývají také obvykle vypnuty optimalizace, což sice zpomaluje běh výsledného kódu, ale umožňuje dokonalé ladění, jako např. čtení a zobrazování obsahu proměnných.

Rozdíly v makrech

Prvním z rozdílů v kódu bývají většinou tzv. podmíněné překlady. Sestavujeme-li Debug verzi programu, je v prostředí MS Visual C++ většinou definována konstanta _DEBUG, při sestavení Release verze konstanta NDEBUG. Obě konstanty jsou přidány prostředím Visual C++ při vytvoření projektu, programátoři je tam většinou nechávají, takže se jejich použití stalo téměř zvykem. Zde je příklad kódu, který ukazuje použití těchto konstant:

#ifdef _DEBUG
    printf("The operation succeeded\n");
#endif

Při použití podmíněných překladů je nutné vyhnout se konstrukcím, které mají vliv i na kód mimo podmíněný překlad. Takovéto chyby ale nebývají příliš časté, protože programátoři většinou dávají do takovýchto podmíněných překladů pouze různé kontrolní výpisy. Příklad chybně napsaného kódu:

#ifdef _DEBUG
    result = GetLastError();
#endif

    // Now what ?
    if(result == ERROR_SUCCESS)
        // ...

Některé rozdíly nejsou ale na první pohled tolik patrné, jako v předchozím případě. Typickým příkladem jsou běžně používaná makra, která jsou ale definována jinak v Debug verzi a jinak v Release verzi, jako např. assert(), ASSERT() nebo TRACE(). Viz následující ukázka:

    assert((result = CreateObject()) != NULL);

V debug verzi je makro assert definováno tak, aby v případě, že podmínka v závorce neplatí, program zobrazil chybové hlášení se jménem zdrojového souboru a číslem řádku. V Release verzi je ale definice makra kompletně potlačena:

#ifdef _DEBUG
    define assert(cond)   if(!(cond))  ShowAssertMessage(__FILE__, __LINE__)
#else
    define assert(cond)  /**/
#endif

Kód uvedený výše bude ze zdrojového textu v Release verzi "vymazán" (nahrazen prázdným komentářem) a chyba je na světě. Obvykle si takovéto chyby všimne buďto nadřízený, nebo v tom horším případě zákazník. Murphyho zákony bývají v tomto neúprosné. Správný zápis kódu je takovýto:

    result = CreateObject();
    assert(result != NULL);

Při použití podmíněných překladů a maker assert, TRACE a jiných je nutné tedy dbát na to, aby kód po pomyslném odstranění makra byl stále funkční, neprovádět tedy volání funkcí, přiřazení proměnných, alokace paměti a jiné.

Alokace pomocí new a delete

Dalším zdrojem chyb, které se při nepozornosti objeví až v release verzi, může být "nepatrné" přetečení bufferu alokovaného pomocí operátoru new:

    char * szStr = new char[4];
    sprintf(szStr, "%04u", 2003);

Přetečení paměti alokované pomocí new Zde programátor opomněl fakt, že za čtyřmi znaky následuje ukončovací nulový znak, celkově je tedy nutné alokovat pět znaků. Tento příklad ale v debug verzi programu většinou projde. Proč ?
Debug verze operatoru new alokuje vždy o 8 bytů více, čtyři byty před bufferem a čtyři byty za bufferem naplní hodnotou 0xFD, alokovaný buffer pak hodnotou 0xCD (platí pro MS Visual C++). Dvojslova před blokem a za blokem pak testuje operátor delete, který zobrazí zprávu do výstupního okna (Output window). Pokud toto upozornění programátor přehlédne, program v release verzi většinou spadne, protože release verze operátoru new nealokuje žádná data navíc. Naštěstí tvůrci Visual C++ do verze 6.0 přidali mnohem lépe viditelné upozornění, které se přehlédnout nedá (viz obr. vpravo).

Lokální proměnné v debug a release verzi

Jeden z méně viditelných rozdílů mezi Debug a Release verzí je vyplňování lokálních proměnných hodnotou 0xCC. Viz tento kód:

    int number;

    printf("The value of 'number' is %08lX.\n", number);
Pokud program zkompilujete v MS Visual C++, dostanete v debug verzi výstup
The value of 'number' is CCCCCCCC.

V Release verzi nejsou lokální proměnné implicitně inicializovány. Ačkoliv tento rozdíl nejspíš nebude zdrojem chyb, není na škodu připomenout nutnost inicializace proměnných (Omlouvám se všem, kteří inicializují proměnné automaticky bez ohledu na to, zda je to nutné nebo ne). Vyplňování zásobníku lze zakázat smazáním volby /GZ z nastavení překladače. (Pak ale není možné program ladit s použitím ladicích informací.

Zásobník v debug a release verzi

Zkuste přeložit tento program:

void main(void)
{
    int i = 1 + 1;
}
Pokud vám assembler nedělá problémy, prohlédněte kód fce main() v assembleru (View\Debug Windows\Dissassembly). Uvidíte tam tento kód (samozřejmě bez komentářů)
00401010   push        ebp                      // Tzv. "Stack frame"
00401011   mov         ebp,esp                  
00401013   sub         esp,44h                  // Lokální proměnné
00401016   push        ebx                      // Uložení registrů
00401017   push        esi
00401018   push        edi
00401019   lea         edi,[ebp-44h]            // Vyplnění lokálních proměnných
0040101C   mov         ecx,11h                  // hodnotou 0xCC
00401021   mov         eax,0CCCCCCCCh
00401026   rep stos    dword ptr [edi]
00401028   mov         dword ptr [ebp-4],2      // Inicializace proměnné "i"
0040102F   pop         edi                      // Obnova registrů
00401030   pop         esi
00401031   pop         ebx
00401032   mov         esp,ebp                  // Odstranění "Stack frame"
00401034   pop         ebp 
00401035   ret                                  // return
Všiměte si, že kompilátor rezervoval 0x44 (68) bytů pro lokální proměnné. 4 byty zabírá proměnná "int i". Zbylých 64 bytů je na zásobníku alokováno jaksi navíc, pro účely kontroly zásobníku. Vypadá to tedy, jako bychom napsali tento program:
void main(void)
{
    int  i = 1 + 1;
    char invisible[0x40];
}

Těchto 64 bytů může způsobit problémy. Viz následující příklad

    char year[4];
    
    sprintf(year, "%i", 1987);
Program provedl neplatnou operaci a bude ukončen

Zde je podobná chyba jako v odstavci u dynamické alokace paměti. Na uložení řetězce je potřeba 5 bytů, vč. nulového ukončovacího znaku. Tento příklad v debug verzi projde, MS Visual C++ neprovede žádné testy (aspoň do verze 6.0). V Release verzi ale dojde k přepsání zásobníku, které má fatální následky pro běh programu (viz. obr).

Použití assembleru v kódu

Někdy je nutné použít v kódu C/C++ vložený (inline) assembler pro rychlejší průběh. Jeho použití je spíše pro znalce, protože je nutné přesně vědět, co daný kód udělá. Při použití assembleru je (mimo jiné) potřeba dbát na to, že použité registry procesoru bývají v debug i v release verzi použity k ukládání hodnot proměnných. Některé proměnné nemusí být vůbec uloženy v paměti, ale pouze v registru CPU. Tato skutečnost ale platí většinou pouze v release verzi. Pokud vložený kód v assembleru změní hodnotu registru, v němž je uložena proměnná, povede to ke špatné funkci nebo k pádu programu.

Další problém plynoucí z použití inline assembleru si pamatuju z dávných dob, kdy na poli vývojářských nástrojů vévodilo Borland C++, se svým na tehdejší dobu velice komfortním uživatelským rozhraním. Použil jsem kód, který modifikoval registr SI. Věděl jsem, že rámec každé funkce vytvořený překladačem ukládá hodnotu tohoto registru na zásobník. Pro vytvoření release verze jsem používal řádkový překladač bcc.exe, který měl extrémně rychlý překlad a také velice dobrou optimalizaci. Součástí této optimalizace bylo ale i to, že neukládal hodnotu žádného regstru kromě BP. Hledáním chyby jsem strávil dva dny ...

Nesprávně modifikovaná data vygenerovaná ClassWizardem

Zajímavý problém ze své praxe mi zaslal ing. S. Rataj. Bylo nutné naprogramovat dialogové okno, obsahující ListView. Běžnou praxí je, že dvojité kliknutí na prvku ListView vyvolá podobnou akci, jako stlačení tlačítka OK na dialogu. Programátor ručně přepsal jména funkcí v mapě zpráv vygenerované ClassWizardem z:

// Původní kód
BEGIN_MESSAGE_MAP(CMyDialog, CDialog)
    //{{AFX_MSG_MAP(CMyDialog)
    ON_BN_CLICKED(IDOK, OnClickedOK)
    ON_NOTIFY(NM_DBLCLK, IDC_LIST_BOX, OnDblclkListBox)
    ON_NOTIFY(NM_RETURN, IDC_LIST_BOX, OnReturnListBox)
    //}}AFX_MSG_MAP
END_MESSAGE_MAP()

na:

// Nový kód
BEGIN_MESSAGE_MAP(CMyDialog, CDialog)
    //{{AFX_MSG_MAP(CMyDialog)
    ON_BN_CLICKED(IDOK, OnClickedOK)
    ON_NOTIFY(NM_DBLCLK, IDC_LIST_BOX, OnClickedOK)
    ON_NOTIFY(NM_RETURN, IDC_LIST_BOX, OnClickedOK)
    //}}AFX_MSG_MAP
END_MESSAGE_MAP()

Cílem bylo, aby více událostí (stlačení tlačítka OK, dvojité kniknutí na položku v ListView, stlačení klávesy Enter na prvku ListView) bylo obsluhováno jedinou funkcí. Takto modifikovaný kód překladač zkompiluje "bez mrknutí oka" a Debug verze programu funguje bez problémů. Release verze ale padá při dvojitém kliknutí nebo při stisku Enter na prvku ListView.

Důvodem, proč program padá, je rozdílná požadovaná deklarace funkce pro obsluhu stisku tlačítka a funkce pro obsluhu zprávy WM_NOTIFY, která přijde do dialogového okna po dvojitém kliknutí:

    afx_msg void OnClickedOK();
    afx_msg void OnDblclkListBox(NMHDR* pNMHDR, LRESULT* pResult);

Protože ale mapa zpráv MFC je několikanásobné makro plné nejrůznějších přetypování, do položky mapy zpráv se uloží ukazatel na funkci, přetypovaný na PAFX_MSG, což je:

void (AFX_MSG_CALL CCmdTarget::*)(void);

Tedy funkce nepřijímající žádné parametry a nevracející žádnou hodnotu. Při obsluze zprávy WM_NOTIFY dojde ke zpětnému přetypování na funkci se dvěma parametry, odpovídající OnDblclkListBox. To je ale chyba, protože funkce OnClickOK žádné parametry nemá. Proč to ale funguje v Debug verzi a ne v Release ? Odpověď je v assemblerovém výpisu volání obsluhy WM_NOTIFY. (Upozorňuji "ne-assembleristy", že následující výklad je poněkud drastický):

// C++
    (pTarget->*mmf.pfn_NOTIFY)(pNotify->pNMHDR, pNotify->pResult);
    return;

// Assembler
    mov         edx,dword ptr [pNotify]     // EDX obsahuje ukazatel pNotify
    mov         eax,dword ptr [edx]         // EAX obsahuje ukazatel pResult
    push        eax                         // Vložíme pResult do zásobníku
    mov         ecx,dword ptr [pNotify]     // ECX obsahuje ukazatel pNotify
    mov         edx,dword ptr [ecx+4]       // EDX obsahuje ukazatel pNMHDR
    push        edx                         // Vložíme pNMHDR do zásobníku
    mov         ecx,dword ptr [pTarget]     // Do registru ECX vložíme this
    call        dword ptr [mmf]             // Zavoláme funkci OnClickedOK (Parametry
                                            // ze zásobníku odstraní volaná funkce.
                                            // V tomto případě ale neodstraní nic,
                                            // protože nemá parametry.
    jmp         __Return                    // Skok na návrat z funkce

__Return:
    mov         eax,dword ptr [bResult]     // Do EAX vložíme návratovou hodnotu z funkce
    pop         edi                         // Obnovíme EDI. Bude obsahovat pNMHDR !!!
    pop         esi                         // Obnovíme ESI. Bude obsahovat pResult !!!
    pop         ebx                         // Obnovíme EBX. Bude obsahovat hodnotu
                                            // která měla být v EDI
    mov         esp,ebp                     // Obnovíme zásobník. Protože EBP nebyl změněn,
                                            // je i hodnota ESP nastavená správně.
    pop         ebp                         // Obnovíme EBP (správná hodnota)
    ret         1Ch                         // return

Po volání funkce OnClickedOK() je ukazatel zásobníku posunutý o dvě 32bitová slova nahoru (to jsou ty dva parametry, které do zásobníku byly vloženy pro volání funkce obsluhující zprávu WM_NOTIFY). Hodnota zásobníku je sice obnovena na původní správnou díky tomu, že jeho obsah ESP je "zazálohován" do registru EBP, ale než se tak stane, funkce "stihne" změnit hodnoty registrů EBX, ESI a EDI. To v Debug verzi většinou nevadí, protože hodnoty registrů jsou neustále načítány z proměnných před každým použitím. V Release verzi díky optimalizacím registry EBX, ESI, EDI (příp. i další) obsahují hodnoty proměnných, pointerů aj. V Release verzi tedy dojde k nečekané změně hodnoty některé proměnné, což má nepředvídatelné následky (většinou ale pád programu).

Řešení tohoto problému je jednoduché. Neměňte mapu zpráv vygenerovanou ClassWizardem. Pokud chcete obsluhovat více zpráv jednou funkcí, volejte ji z handleru vygenerovaného ClassWizardem:

void CMyDialog::OnClickedOK() 
{
    // TODO: Add your control notification handler code here
}

void CMyDialog::OnDblclkListBox(NMHDR* pNMHDR, LRESULT* pResult) 
{
    OnClickedOK();
    *pResult = TRUE;
}

void CMyDialog::OnReturnListBox(NMHDR* pNMHDR, LRESULT* pResult) 
{
    OnClickedOK();
    *pResult = TRUE;
}

Ostatní problémy

Kromě uvedených problémů se mohou objevit i další, např.

Co dělat, když release verze nefunguje ?

Rady pro případ, že se vám něco takového stane:

Odkazy