Сообщений 9    Оценка 215        Оценить  
Система Orphus

Как пережить release-версию

Автор: Dr. Joseph M. Newcomer
Перевод: Алексей Остапенко
Источник: Surviving the Release Version
Опубликовано: 18.06.2001
Исправлено: 10.10.2005
Версия текста: 1.2

Небольшая выдержка из моей биографии
Ошибки компилятора
Вопросы распределения памяти
Неинициализированные локальные переменные
Диапазонные ошибки (Bounds Errors)
Ошибки связывания
"Ошибки" компилятора
ASSERT и VERIFY
(И снова) Ошибки компилятора
"Библиотечный Ад"
Методы диагностики
Не оптимизируйте
Оптимизируйте только вычисления
Резюме
Другие ссылки

Отлично, ваша программа работает. Вы протестировали все, что возможно. Пришло время выпускать продукт. И вы собираете финальную версию.

И тут все рассыпается в прах.

Вы получаете ошибки доступа к памяти, диалоги не появляются, контролы не работают, результаты выводятся неправильно - появляется либо часть, либо все эти проблемы. Плюс несколько дополнительных проблем, специфических для вашего приложения.

И что теперь?

Именно этому целиком посвящен данный обзор.

Небольшая выдержка из моей биографии

Кусочек биографии: Я работал с оптимизирующими компиляторами с 1969 г. Моя докторская диссертация (1975) была посвящена автоматической генерации сложных оптимизаций для оптимизирующего компилятора. Моя последующая работа включала использование сильно оптимизирующего компилятора (Bliss-11) при построении большой (500K строк исходного кода) операционной системы для мультипроцессорной системы. После этого я был одним из архитекторов проекта PQCC (компилятора компиляторов производственного уровня) в университете Карнеги-Мелона, этот проект был направлен на упрощение создания мощных оптимизирующих компиляторов. В 1981 году я покинул университет, чтобы работать в Tartan Laboratories, компании, разрабатывавшей сильно оптимизирующие компиляторы, где я был одним из основных участников в инструментальной разработке для компиляторов. Я жил, работал, собирал, отлаживал, и справлялся с оптимизирующими компиляторами в течении более 30 лет.

Ошибки компилятора

Обычно первой реакцией является утверждение: "Оптимизатор содержит ошибки". Хотя это и может быть правдой, реально это одна из последних причин. Более вероятно, что что-то еще не так с вашей программой. Мы вернемся в вопросу "ошибок компилятора" чуть позже. Но в первом предположении компилятор работает правильно, и у вас другая проблема. Поэтому мы сперва обсудим такие проблемы.

Вопросы распределения памяти

Отладочная версия MFC-runtime распределяет память не так, как release-версия. В частности, отладочная версия резервирует небольшое пространство в начале и в конце каждого блока памяти, поэтому ее конфигурации распределения памяти несколько иные. Изменения в распределении памяти могут вызвать появление проблем, которые не проявились бы в отладочной версии, но почти всегда это реальные проблемы, такие как ошибки в вашей программе, которые каким-то образом ухитрились не быть обнаруженными в отладочной версии. Они обычно являются редкостью.

Почему они редки? Потому что отладочная версия менеджера памяти MFC инициализирует весь блок абсолютно бессмысленными значениями, так что попытка использовать участок памяти, который вы не выделяли, приведет к немедленной ошибке доступа в отладочной версии. Более того, когда блок памяти освобождается, он инициализируется другими значениями, поэтому, если вы сохранили любые указатели на этот блок и пытаетесь использовать его после освобождения, вы также почти сразу увидите неверное поведение программы.

Отладочный менеджер памяти также проверяет память в начале и в конце выделенного блока, чтобы определить не был ли он поврежден каким-либо способом. Типичная проблема заключается в выделении блока из n значений под массив с последующим доступом к элементам от 0 до n, вместо от 0 до n-1, таким образом перезаписывается область после конца массива. Такая ситуация в большинстве случаев приведет к исключению. Но не всегда. И это оставляет возможность для сбоя.

Память выделяется квантуемыми блоками, при этом размер кванта не определен, но равен чему-то вроде 16 или 32. Таким образом, если вы выделяете массив из 6 DWORD'ов (размер = 6 * sizeof(DWORD) байт = 24 байта), то менеджер памяти в действительности предоставит 32 байта (один 32-байтовый квант или два 16-байтовых кванта). Поэтому, если вы запишете element[6] (седьмой элемент), то перезапишите часть "мертвого пространства", и ошибка не будет обнаружена. Но в финальной версии квант может быть равен 8 байтам, и может быть выделено три 8-байтовых кванта, и запись в элемент [6] массива перезапишет часть структуры распределения памяти, принадлежащей следующему блоку. После этого всему настает конец. Эта ошибка может даже не проявиться до завершения программы. Вы можете построить аналогичные ситуации с "граничными условиями" для любого размера кванта. Поскольку размер кванта одинаков для обеих версий менеджера памяти, но отладочная версия добавляет скрытое пространство для собственных целей, вы получите различные конфигурации распределения памяти в отладочном и финальном вариантах.

Неинициализированные локальные переменные

Возможно, величайшей одиночной причиной сбоев при переходе с отладочной на финальную версию является наличие неинициализированных локальных переменных. Рассмотрим простой пример:

thing * search(thing * something)
BOOL found;
for(int i = 0; i < whatever.GetSize(); i++)
{
    if(whatever[i]->field == something->field)
    { /* нашли */
        found = TRUE;
        break;
    } /* нашли */
}
if(found)
    return whatever[i];
else
    return NULL;
}

Выглядит довольно прямо, за исключением ошибки с отсутствием инициализации переменной found в FALSE. И эта ошибка ни разу не проявилась в отладочной версии! Но вот что произойдет в финальной версии - будет возвращен элемент whatever[n] массива whatever, содержащего n элементов, т.е. очевидно неверное значение, что в дальнейшем приведет к ужасному сбою в другой части программы. Почему это не проявляется в отладочной версии? Потому что в отладочной версии, по счастливой случайности, стартовое значение found всегда равнялось 0 (FALSE), и когда цикл завершался, не найдя ничего, переменная правильно показывала, что ничего не найдено, и возвращался NULL.

В чем отличие стека? В отладочной версии указатель локального фрейма (frame pointer) всегда помещается в стек на входе в процедуру, и переменные почти всегда размещаются в стеке. Но в финальной версии оптимизация компилятора может выявить, что указатель фрейма не нужен, или местоположение переменных может быть вычислено относительно указателя стека (в компиляторах, над которыми я работал, мы называли этот метод имитация указателя фрейма (frame pointer simulation)), поэтому указатель локального фрейма не помещается в стек. Более того, компилятор может решить, что гораздо более эффективно разместить переменную, такую как i из предыдущего примера, в регистре, а не использовать значение в стеке. Таким образом, начальное значение переменной может зависеть от многих факторов (переменная i явно инициализирована, но что, если переменной была бы found?).

Не существует иного способа обнаружения неинициализированных локальных переменных без помощи инструмента статического анализа, кроме тщательного чтения кода и включения высокоуровневой диагностики компилятора. Мне особенно нравится Gimpel Lint (см. http://www.gimpel.com) - это отличный инструмент, и я его сильно рекомендую.

Диапазонные ошибки (Bounds Errors)

Существует множество корректных оптимизаций, которые выявляют скрытые в отладочной версии ошибки. Да, иногда это ошибка компилятора, но в 99% случаев это чисто логическая ошибка, которая только кажется безобидной в отсутствие оптимизации, но является фатальной при ее наличии. Например, у вас присутсвует доступ к массиву недостаточной размерности. Рассмотрим код в следующей обобщенной форме:

void func()
{
    char buffer[10];
    int counter;

    lstrcpy(buffer, "abcdefghik"); // 11-байтовое копирование, включая NUL
    ...
}

В отладочной версии нулевой байт в конце строки перезаписывает старший байт счетчика, но пока counter не станет > 16M, это неопасно, даже если counter используется. Но оптимизирующий компилятор переместил counter в регистр, и он никогда не появится в стеке. Для него не выделено место. Нулевой байт перезаписывает данные, следующие за массивом buffer, кторые могут быть адресом возврата из функции, вызывая ошибку доступа на выходе из функции.

Конечно, этот пример чувствителен ко всем видам мелких особенностей компоновки программы. Если бы вместо этого программа была бы такой

void func()
{
    char buffer[10];
    int counter;
    char result[20];

    wsprintf(result, _T("Result = %d"), counter);
    lstrcpy(buffer, _T("abcdefghik")); // 11-байтовое копирование, включая NUL
    ...
}

то нулевой байт, который ранее перекрывал старший байт counter (что не имеет значения в данном примере, т.к. counter, очевидно, более не нужен после исполнения печатающей его строки), теперь перезаписывает первый байт result, приводя к тому, что строка result теперь выглядит пустой без всяких видимых причин. Если бы result был переменной типа char * или каким-либо другим указателем, то вы бы получили ошибку доступа при попытке его разадресации.

В таких случаях вам придется создать версию исполняемого модуля с включенной отладочной информацией, а затем использовать возможность остановки при изменении значения для отслеживания ошибочной перезаписи. Иногда вам придется стать очень изобретательными, чтобы отловить такие ошибки.

ПРИМЕЧАНИЕ
Я через это прошел. Однажды на ежемесячном собрании я получил премию от компании за нахождение фатальной ошибки перезаписи памяти, которая была ошибкой "седьмого уровня", а именно, указатель, превращенный в мусор перезаписью поверх него другого валидного (но неверного) указателя, приводил к разрушению другого указателя, что приводило к неверному вычислению индекса, что приводило ... - и так разрушения до седьмого уровня, что в конце-концов вызывало фатальную ошибку доступа. Т.к. для данной системы не было возможности сгенерировать финальную версию с отладочной информацией, я провел чистых 17 часов, пошагово исполняя инструкции, отслеживая обратные связи, и последовательно сужая зону поиска. У меня было 2 терминала - на одном исполнялась отладочная, а на другом финальная версии программы. После того, как я нашел ошибку, по отладочной версии было очевидно, что происходило не так, но в неоптимизированном коде описанное выше явление скрывало истинную ошибку.

Ошибки связывания

Типы связывания

Некоторые функции требуют специфического типа связывания (linkage type), такого как __stdcall. Другие функции требуют корректной передачи параметров. Возможно, неверные типы связывания являются одними из самых частых ошибок. Когда функция требует связывания __stdcall, вы должны указать __stdcall в ее декларации. Если она не требует __stdcall, вы должны не использовать связывание __stdcall. Заметим, что вы редко, если вообще, увидите "чистое" определение связывания __stdcall. Вместо этого, есть множество макросов типа связывания, таких как WINAPI, CALLBACK, IMAGEAPI, и даже древний (и несомненно устаревший) PASCAL, которые все определены как __stdcall. Например, высокоуровневая функция для AfxBeginThread определена как функция, чей прототип использует тип связывания AFX_THREADPROC:

UINT (AFX_CDECL * AFX_THREADPROC)(LPVOID);

который, как вы можете догадаться, является типом CDECL (т.е., не __stdcall). Если бы вы определили свою потоковую функцию как

UINT CALLBACK MyThreadFunc(LPVOID value)

и запустили поток через

AfxBeginThread((AFX_THREAD_PROC)MyThreadFunc, this);

то явное приведение типа (часто добавляемое для устранения предупреждения компилятора) обмануло бы компилятор, заставив его сгенерировать код. Это часто приводит к вопросам "Моя потоковая функция роняет приложение по завершению потока, но только в финальном варианте". Почему этого не происходит в отладочной версии мне совсем неясно, но в большинстве случаев, которые мы рассматривали, проблема была в неверном типе связывания потоковой функции. Поэтому, когда вы видите сбой аналогичный этому, убедитесь в том, что все типы связывания верно указаны на своих местах. Опасайтесь приведения типов функций, вместо этого пишите вызов так

AfxBeginThread(MyThreadFunc, (LPVOID)this);

что позволит компилятору проверить тип связывания и число параметров.

Число параметров

Использование приведений также приводит к проблемам с числом параметров. Большая их часть будет фатальной в отладочной версии, но по каким-то причинам часть из них не обнаружится до финального выпуска. В частности, любая функция со связыванием __stdcall в любой из его форм должна получать корректное число аргументов. Обычно это выявляется непосредственно во время компиляции, если вы не использовали приведение прототипа функции (как приведение (AFX_THREADPROC) в предыдущем разделе)) для обхода проверки компилятора. Это почти всегда приводит к фатальной ошибке при возврате из функции.

Наиболее часто проблема проявляется при использовании пользовательских сообщений. У вас есть сообщение, не использующее значений WPARAM и LPARAM, поэтому вы пишете

wnd->PostMessage(UWM_MY_MESSAGE);

для упрощения посылки сообщения. Затем вы пишете следующий обработчик

afx_msg void OnMyMessage(); // неверно!

и программа падает в финальной версии. Опять, я не исследовал почему это не приводит к сбою в отладочной версии, но мы видели такое всякий раз, когда создавалась финальная версия. Правильная сигнатура для пользовательского сообщения всегда и без исключений такова

afx_msg LRESULT OnMyMessage(WPARAM, LPARAM);

Вы обязаны возвращать значение и должны передавать параметры, как указано (и вы обязаны использовать типы WPARAM и LPARAM, если вам нужна совместимость с 64-битным миром; определенное количество людей, "знавших", что WPARAM означает WORD и просто писавших (WORD, LONG) в своем коде под Win16, расплачивались при попытке перейти на Win32, где это, в действительности, (UNSIGNED LONG, LONG). И все опять будет иначе в Win64. Так зачем делать неправильно, пытаясь показаться умным?).

Заметим, что если вы не используете значения параметров, то вы не даете им имен. Таким образом, ваш обработчик OnMyMessage написан следующим образом

LRESULT CMyClass::OnMyMessage(WPARAM, LPARAM)
{
    ...тут что-то делаем...
    return 0; // логически void, всегда 0
}

"Ошибки" компилятора

Оптимизирующий компилятор делает различные предположения о той сущности, с которой он имеет дело. Проблема в том, что взгляд компилятора на реальность целиком базируется на ряде предположений, все из которых программист на C может очень легко нарушить. Результатом этой неправильной интерпретации реальности будет то, что вы можете обмануть компилятор, заставив его генерировать "плохой код". В действительности, он не плохой - это совершенно правильный код, при условии, что предположения компилятора были верны. Если вы явно или неявно лгали компилятору, то с него взятки гладки.

Ошибки использования множественных ссылок (Aliasing errors)

Ссылка (alias) на область памяти - это адрес этой области. Обычно компилятор предполагает, если не указано другое, присутсвие множественных ссылок (что типично для программ на C). Вы можете получить лучший код, если укажите компилятору, что он может предположить отсутствие множественных ссылок, таким образом, рассчитанные значения останутся неизменными во время вызовов функций. Рассмотрим следующий пример:

int n;
int array[100];
int main(int argc, char * argv)
{
    n = somefunction();
    array[0] = n;
    for(int i = 1; i < 100; i++)
    array[i] = f(i) + array[0];
}

Пример выглядит довольно просто, он вычисляет функцию от i - f(i), определением которой мы не будем себя утруждать в данный момент, и добавляет к ней значение элемента массива. Поэтому умный компилятор говорит: "Смотрите, array[0] не модифицируется в теле цикла, поэтому мы можем изменить код, чтобы он сохранял значение в регистре, и переставить код":

    register int compiler_generated_temp_001 =somefunction();
    n = compiler_generated_temp_001;
    array[0] = compiler_generated_temp_001;
    for(int i = 1; i < 100; i++)
    array[i] = f(i) + compiler_generated_temp_001;

Эта оптимизация, являющаяся комбинацией оптимизации инварианта цикла (loop invariant optimization) и распространения значения (value propagation), работает лишь в предположении отсутствия модификации array[0] функцией f(i). Но затем мы определяем

int f(int i)
{
    array[0]++;
    return i;
}

Заметим, что теперь мы нарушили предположение о постоянстве array[0] - существует дополнительная ссылка на значение. В данном случае эту ссылку очень легко увидеть, но при наличии сложных структур со сложными указателями вы можете получить в точности такую же ситуацию, и она не обнаружима во время компиляции или статическим анализом программы.

Также заметим, что компилятор VC++, по-умолчанию, предполагает существование множественных ссылок. Вы должны принять явные меры для изменения этого предположения. Но не стоит этого делать , за исключением очень определенных ситуаций (см. обсуждение директив оптимизации).

const и volatile

Существуют атрибуты, которые вы можете добавлять к декларациям. В определении переменной ключевое слово const указывает, что "эта переменная никогда не изменяется", а префикс volatile говорит, что "объект изменяется непрогнозируемым образом". Хотя, эти указания имеют очень мало значения при компиляции в отладочном режиме, они оказывают сильное воздействие на компиляцию в release-режиме. И если вы ошибочно их не использовали или использовали их неправильно, Вы Обречены.

Атрибут const при переменной или функции указывает на постоянство величины. Это позволяет оптимизирующему компилятору сделать определенные предположения об этой величине и делает возможным использование таких оптимизаций, как "распространение величины" и "распространение константы". Например,

int array[100];
void something(const int i)
{
    ... = array[i]; // 1-е использование
    // какие-то другие действия
    ... = array[i]; // 2-е использование
}

Указание const позволяет компилятору считать, что значение i одинаково в точке использования 1 и точке использования 2. Более того, поскольку array размещен статически, адрес array[i] нужно вычислить только один раз. И может быть сгенерирован такой же код, как если бы было написано:

int array[100];
void something(const int i)
{
    int * compiler_generated_temp_001 = &array[i];
    ... = *compiler_generated_temp_001; // 1-е использование
    // какие-то другие действия
    ... = *compiler_generated_temp_001; // 2-е использование
}

Фактически, если объявление выглядит так

const int array[100] = {.../* набор значений */ }

будет сгенерирован такой же код, как и из

void something(const int i)
{
    int compiler_generated_temp_001 = array[i];
    ... = compiler_generated_temp_001; // 1-е использование
    // какие-то другие действия
    ... = compiler_generated_temp_001; // 2-е использование
}

Таким образом, const не только обеспечивает проверку во время компиляции, но и позволяет компилятору генерировать меньший и более быстрый код. Заметим, что вы можете вызвать нарушение признака const, используя явные приведения типа и различные "скользкие" методы программирования.

Определение volatile аналогично const, но имеет прямо противоположный смысл: не может быть сделано никаких предположений о постоянстве значения. Например, цикл

// уровень модуля или уровень, глобальный для функции
int n;
 
// внутри некоей функции
 
while(n > 0)
{
    count++;
}

будет быстро преобразован оптимизирующим компилятором в

if(n > 0)
    for(;;)
        count++;

и это совершенно правильная трансляция. Поскольку ничто в цикле не может изменить значение n, нет причин когда-либо повторять проверку. Эта оптимизация - пример вычисления инварианта цикла (loop invariant computation). И оптимизирующий компилятор "вытащит его" из цикла.

Но что, если остаток программы таков:

registerMyThreadFlag(&n);
while(n > 0)
{
    count++;
}

и поток, использующий зарегистрированную вызовом registerMyThreadFlag переменную, устанавливает значение переменной, адрес которой передан вызову. Все полетит к черту - цикл никогда не завершится!

Следовательно, это должно быть учтено путем добавления volatile к объявлению n:

volatile int n;

Это описание указывает компилятору, что он не имеет свободы делать предположение о постоянстве значения. Он сгенерирует код, проверяющий значение n в каждой итерации цикла, поскольку вы явно сказали ему о небезопасности предположения об инвариантности n в цикле.

ASSERT и VERIFY

Многие программисты свободно вставляют в код макросы ASSERT. Обычно это хорошая идея. Хорошей чертой ASSERT являет то, что его использование ничего не стоит для финальной версии, т.к. в ней этот макрос имеет пустое тело. Упрощенно, вы можете представить себе определение макроса ASSERT так

#ifdef _DEBUG
#define ASSERT(x) if( (x) == 0) report_assert_failure()
#else
#define ASSERT(x)
#endif

(Реальное определение более сложно, но детали не играют роли в данном случае). Это работает прекрасно, если вы делаете что-то вроде

ASSERT(whatever != NULL);

что очень просто; и пропуск вычисления этого теста в финальной версии не приносит вреда. Но кто-нибудь напишет так

ASSERT( (whatever = somefunction() ) != NULL);

что приведет к ужасному сбою в release-версии, поскольку присваивание никогда не выполнится, т.к. нужный код не сгенерирован (мы отложим обсуждение фундаментальной вредности включенных присваиваний до других очерков, которые еще предстоит написать. Примите как данное, что если вы пишите операторы присваивания внутри if-оператора или в любом другом контексте, то вы совершаете серьезное нарушение стиля программирования!).

Еще один типичный пример

ASSERT(SomeWindowsAPI(...));

вызывающий проверочное исключение при ошибке в вызове API. Но в финальной версии системы этот вызов никогда не выполняется!

Вот для чего предназначен VERIFY. Представьте определение VERIFY так

#ifdef _DEBUG
#define VERIFY(x) if( (x) == 0) report_assert_failure()
#else
#define VERIFY(x) (x)
#endif

Заметим, что это совершенно иное определение. Из финальной версии выбрасывается именно if-проверка, а нужный код все равно выполняется. Правильными вариантами предыдущих неверных примеров будут

VERIFY((whatever = somefunction() ) != NULL);
VERIFY(SomeWindowsAPI(...));

Данный код будет корректно работать как в отладочной, так и в финальной версии, но в отладочной версии не будет исключения ASSERT, если проверка выдаст FALSE. Упомяну, что я также видел такой код

VERIFY( somevalue != NULL);

что просто глупо. Реально он означает, что в release-версии будет генерироваться код, вычисляющий значение выражения, которое игнорируется. Если оптимизация включена, компилятор в действительности достаточно сообразителен, чтобы определить, что делается нечто бессмысленное, и выбросить код, который был бы сгенерирован (но только если у вас Professional или Enterprise-версия компилятора!). Но вы можете создать неоптимизированную финальную версию, что мы также обсуждаем в данном очерке, и тогда предыдущий VERIFY просто тратил бы впустую время и место.

(И снова) Ошибки компилятора

Оптимизирующие компиляторы - это очень изощренные наборы кода. Они так сложны, что обычно никто не понимает весь компилятор. Более того, оптимизационные решения могут тонко и непредсказуемо взаимодействовать. Я через это прошел.

Microsoft проделала на удивление хорошую работу по тестированию (QA) своих оптимизирующих компиляторов. Я не хочу сказать, что они прекрасны, но они действительно очень и очень неплохи. Намного лучше, чем многие коммерческие компиляторы, которые я использовал в прошлом (однажы я использовал компилятор, который "оптимизировал" константную if-проверку, выполняя else, когда значение равнялось TRUE, и - then, если значение равнялось FALSE. И разработчики сказали нам: "Мы исправим это в следующей версии, где-нибудь в следующем году". Фактически, я думаю, они выпали из конкуренции еще до следующего релиза своего компилятора, что не удивило никого, кто когда-либо был клиентом).

Но наиболее вероятно, что "ошибка компилятора" - это результат нарушения предположений компилятора, а не обычная ошибка в компиляторе. Это так по моему опыту.

Более того, возможно, что это даже не ошибка в вашем коде. Ошибка может быть в разделяемой DLL MFC или в статической библиотеке MFC, в которой программист допустил ошибку, не проявляющуюся в отладочных версиях этих библиотек, но проявляющуюся в release-версиях. И вновь, в Microsoft проделали удивительно хорошую работу по тестированию таких ситуаций, но ни одна процедура тестирования не совершенна.

Однако, Windows Developer's Journal (http://www.wdj.com/) включает ежемесячную публикацию "Bug++ of the Month" ("Ошибка++ месяца"), описывающую реальные чистые ошибки оптимизации компилятора.

Как обойти эти ошибки? Простейшим способом является выключение всей оптимизации компилятора (см. ниже). Существует отличный шанс, что проделав это, вы не ощутите никакой разницы в производительности вашей программы. Оптимизация нужна только там, где она существенна. В остальных случаях она является пустой тратой усилий (см. мой очерк в MVP на эту тему!). И в большинстве случаев для большей части большинства программ классическая оптимизация уже не существенна!

"Библиотечный Ад"

Явление несовместимости наборов DLL приводит к состоянию, обычно называемому "Библиотечным Адом". Даже Microsoft называет его так. Проблема заключается в том, что если DLL A, написанная Microsoft, требует наличия DLL B, также написанной Microsoft, то необходимо, чтобы библиотека A использовала подходящую версию библиотеки B. Конечно, это все происходит из-за нежелания менять имена DLL при каждом релизе, чтобы они включали номер исправления, номер версии или другие полезные индикаторы. Однако, в результате жизнь становится довольно невыносимой.

У Microsoft есть веб-сайт, позволяющий вам определить, совместим ли имеющийся у вас набор библиотек. Смотрите статьи на эту тематику по ссылке http://msdn.microsoft.com/library/techart/dlldanger1.htm. Один из приятных моментов заключен в том, что большая часть этой проблемы исчезает в Win2K и WinXP. Однако, часть все еще остается с нами. Иногда финальная версия вашего кода будет рушиться из-за несовместимости, тогда как отладочная версия будет более устойчива по уже перечисленным причинам.

Однако, существует еще одна скрытая проблема - смесь DLL, использующих разделяемую библиотеку MFC, причем есть и отладочные и release-версии DLL. Если вы используете свою DLL, использующую разделяемую библиотеку MFC, убедитесь, что все ваши DLL или отладочные или релизные. Это означает, что вы никогда, ни при каких условиях не должны полагаться на переменную PATH или путь неявного поиска DLL (Я обнаружил, что вся идея путей поиска - это плохо продуманный ляп, гарантированно вызывающий такого рода проблемы. Я никогда не полагаюсь на путь поиска библиотеки, за исключением загрузки стандартных библиотек Microsoft из %SYSTEM32%. И если вы используете любой внешний путь поиска, то вы сами заслужили все то, что происходит! Заметим также, что вы не должны никогда и ни при каких вообразимых обстоятельствах размещать свои собственные DLL в каталоге %SYSTEM32% по одной причине - Win2K и WinXP однозначно удалят их из-за "Защиты System32", хорошей идеи, которую нужно было насильственно реализовать десятилетие назад).

Не думайте, что использование "статического связывания" библиотеки MFC, разрешит эту проблему! В действительности, это еще больше ее усугубит, т.к. вы получите n несвязанных экземпляров библиотеки MFC, каждый из которых думает, что он правит миром. DLL, следовательно, должна либо использовать разделяемую библиотеку MFC, либо не использовать MFC вообще (количество проблем, возникающих при использовании приватной копии библиотеки MFC, слишком ужасно для упоминания на веб странице, иначе относимой к классу G (G-rated). И в интересах сохранения клавиатур я не буду описывать эти проблемы - вдруг кто-то из вас ест, читая этот текст. Ну, как насчет одной: Карты Оконных Дескрипторов MFC (MFC Window Handle Map). Вы действительно хотите две или более копии карты, каждая из которых может иметь несвязанный с другими набор оконных дескрипторов, и попробовать наладить поведение вашей программы? Я думаю, нет).

Тем не менее, очень важно не допускать смешивания отладочных и релизных DLL, использующих MFC (заметим, что "чистая" релизная не-MFC DLL может вызываться из отладочной версии MFC-программы; это происходит всякий раз со стандартными библиотеками Microsoft для OLE, WinSock, мультимедиа, и т.п.). Отладочные и релизные библиотеки также имеют существенно разные интерфейсы к MFC (я не вникал в детали, но я получал отчеты о проблемах), поэтому вы получите сбои LoadLibrary, ошибки доступа, и т.п.

Не очень приятное зрелище.

Один из способов избежать этого - компиляция подпроектов ваших DLL в каталоги Debug и Release основной программы. Я делаю это так - открываю подпроект DLL, выбираю Project Settings, выбираю закладку Link и вставляю "..\" перед имеющимся здесь путем. Вы должны независимо проделать это для конфигураций Debug и Release (и любых других конфигураций, которые могут у вас быть).

Я также вручную редактирую командную строку, чтобы вставить "..\" перед путем к .lib-файлу, упрощая связывание.

Обратите внимание на желтые выделенные области на следующем рисунке. Верхняя слева указывает, что я работаю с конфигурацией Debug. Средняя справа показывает изменения, проделанные для выходного файла, а нижняя правая демонстрирует ручную правку для переноса .lib-файла.


Методы диагностики

Итак, программа сбоит, а у вас нет идей почему. Ну, вот несколько трюков, которые можно попробовать.

Отключение оптимизации

Одним из приемов, который вы можете применить, является отключение всей оптимизации в финальной версии. Откройте в меню Project|Settings для release-версии, перейдите на закладку C/C++, выберите Optimization в комбо-списке и просто все отключите. Затем выполните Build|Rebuild All и вновь попробуйте запустить программу. Если ошибка исчезла, значит у вас появилась зацепка. Нет, вы все еще не знаете была ли эта ошибка ошибкой оптимизации в прямом смысле, зато вы знаете, что ошибка в вашей программе является последствием оптимизационной трансформации, и она может быть такой же простой как неинициализированная стековая переменная, чье неинициализированное значение чувствительно к оптимизации кода. Не слишком много пользы, но теперь вы знаете чуть больше, чем раньше.

Включение символов

Вы можете отлаживать release-версию программы; просто откройте закладку C/C++, выберите категорию General и установите Program Database for Edit and Continue. Вы также должны выбрать закладку Link и в категории General отметить Generate Debug Information. В частности, если вы выключили оптимизацию, то у вас есть тоже самое отладочное окружение, что и в отладочной версии, за исключением того, что используется неотладочная версия разделяемой библиотеки MFC, т.ч. вы не можете пошагово трассировать программу внутрь библиотечных функций. Если вы не отключили оптимизацию, то существуют условия, при которых отладчик будет лгать вам по поводу значений переменных, т.к. оптимизации могут создать копии переменных в регистрах, о чем не сообщается отладчику. Отладка оптимизированного кода может оказаться тяжелой, т.к. на самом деле вы не можете быть уверены в том, что выдает отладчик. Однако с символьной информацией (и отслеживанием строк кода) вы можете продвинуться дальше чем без нее. Заметим, что в оптимизированной версии операторы могут быть переставлены, вынесены из циклов, никогда не вычисляться и т.п., но плюс в том, что код семантически идентичен коду неоптимизированной версии. Вы на это надеетесь. Но, перестановка кода временами очень затрудняет отладчик в точном определении строки на которой произошла ошибка. Будьте к этому готовы. Обычно вы обнаружите, что ошибки настолько очевидны, что как только вы более-менее знаете где смотреть, более детальная отладочная информация уже не важна.

Выборочное включение/выключение оптимизации

Вы можете использовать опции Project|Settings для выборочного изменения характеристик проекта, пофайлово. Моя обычная стратегия заключается в глобальном отключении оптимизации (в release-версии) и последующем включении ее в существенных модулях, по одному за раз, пока проблема не проявится вновь. В этот момент у вас есть хорошее представление о местонахождении ошибки. Вы также можете использовать директивы pragma для очень узкого контроля над оптимизацией.

Не оптимизируйте

Нужно ли это? - вот в чем вопрос. Вот у вас есть продукт для поставки и база пользователей; наступает крайний срок, и какая-то действительно непонятная ошибка возникает только в финальной версии! Зачем вообще оптимизировать? Существенна ли оптимизация на самом деле? Если нет, то зачем вы впустую тратите время? Просто выключите оптимизацию в release-версии и пересоберите проект. Все. Нет шума и суеты. Чуть больше, чуть медленнее, но имеет ли это значение? Прочтите мой очерк о том, что оптимизация - ваш злейший враг.

Оптимизируйте только вычисления

Как правило, код GUI требует небольшой либо никакой оптимизации по причинам изложенным в моей статье. Но, как я указываю в этой статье, внутренние циклы очень даже существенны. Иногда вы можете даже выборочно включить во внутреннем цикле оптимизацию, которую вы бы не осмелились включить в своей программе глобально, такую как указание определенной процедуре на невозможность множественных ссылок. Для этого вы можете вставить оптимизационные директивы вокруг этой процедуры.

Например, посмотрите в справочнике компилятора тему "pragmas", подтему "affecting optimization". Вы найдете множество ссылок на более детальные описания.

inline-функции

Вы можете заставить любую функцию целиком включаться в код, если компилятор решит, что это принесет достаточную выгоду. Просто добавте атрибут inline к декларации функции. В таком случае C/C++ требует определения функции в заголовочном файле, т.е.

class whatever
{
public:
    inline getPointer() { return p; }
protected:
    something * p;
}

Обычно функция не будет компилироваться как встраиваемая (inline), если только компилятору не указали компилировать inline-функции как встраиваемые, и он не решил, что это можно корректно сделать. Идите и прочитайте описание в документации. Ключи компилятора, разрешающие оптимизацию включений, устанавливаются в Project|Settings; выберите закладку C/C++, затем категорию Optimization, и выберите тип оптимизации из выпадающего списка Inline function expansion. Обычно указания /Obl достаточно для release-версии. Заметим, что если ваша ошибка опять всплывет, у вас будет действительно хорошее представление, где искать.

внутренние функции

Компилятору известно, что определенные функции могут быть вставлены напрямую в код. Неявно встраиваемые функции включают следующие

_lrotl, _lrotr, _rotl, _rotr, _strset, abs, fabs, labs, memcmp, memcpy, memset, strcat, strcmp, strcpy, и strlen.

Заметим, что неявная вставка любой из них в код дает очень слабое преимущество, если только они не находятся в критической по времени части программы. Запомните правило: измеряйте, измеряйте и еще раз измеряйте.

Внутренние функции зачастую увеличивают размер кода, хотя код быстрее. Если вам это нужно, вы можете просто написать

#pragma intrinsic(strcmp)

и все последующие вызовы strcmp будут целиком включены в код. Вы также можете использовать ключ компилятора /Oi, который устанавливается в Project|Settings, закладка C/C++, категория Optimization при выборе Custom и установке Generate Intrinsic Functions. Вероятно, вы никогда не увидите ошибки, возникающей в оптимизированном коде из-за встраивания внутренних функций.

Заметим, что использование вызова strcmp в коде в любом случае может быть очень неудачной идеей - если вы когда-либо подумаете собрать Unicode-версию вашего приложения. Вы должны писать _tcscmp, которая расширяется в strcmp в ANSI- (8-бит на символ) и в _wcscmp в Unicode-приложениях (16 бит на символ).

По настоящему полный контроль

Если у вас есть высокопроизводительный внутренний цикл, вы можете пожелать указать компилятору на его безопасность. Во-первых, укажите везде, где необходимо, модификаторы const и volatile. Затем, включите локальную оптимизацию, например так

#pragma optimize("aw", on)

Эта строка указывает компилятору на то, что он может делать множество глубоких предположений об отсутствии множественных ссылок. Результат будет намного быстрее, и код будет гораздо "тоньше". НЕ указывайте компилятору глобально на отсутствие множественных ссылок! Очень вероятно, что вы себя подставите, т.к. вы повсеместно систематически нарушали данное ограничение (это легко сделать, и очень трудно найти, если оно сделано). Вот почему вы хотите использовать этот тип оптимизации только при очень определенных условиях, там где вы знаете, что обладаете полным контролем над происходящим.

Обычно, когда я делаю такую оптимизацию, я выношу функцию в отдельный файл. Соответсвенно, модифицированна может быть только та функция, которую я хочу оптимизировать.

Резюме

Данный очерк выделяет некоторые основные причины и стратегии борьбы с иногда возникающими проблемами перехода с debug- на release-версию. Простейшая и часто лучшая из них - просто выключить всю оптимизацию в финальной версии. Затем выборочно включить оптимизацию для того 1% вашего кода, который может оказывать сущесвенное влияние. Предположим, что у вас именно столько сущесвенного кода. Для большинства приложений, которые мы пишем, такое малое количество важного кода означает, что вы получите практически одинаковую производительность с оптимизированным и неоптимизированным кодом.

Другие ссылки

Просмотрите несколько дополнительных полезных обзоров Брюса Доусона (Bruce Dawson) на сайте http://www.cygnus-software.com/papers/release_debugging.html Особенно интересен указанный им момент, что вы всегда должны создавать отладочную информацию для вашей финальной версии. Таким образом вы действительно можете отлаживать ошибки в продукте. Не знаю, почему я никогда об этом не думал, но факт, что не думал!


Взгляды, выраженные в данной статье, принадлежат автору, и ни в коей мере не представляют Microsoft и не поддерживаются ей.

Copyright c 2001, The Joseph M. Newcomer Co. Все права защищены.
Переведено с разрешения автора.

    Сообщений 9    Оценка 215        Оценить