Вебинар: C# разработка и статический анализ: в чем практическая польза? - 18.11
64-битные ошибки достаточно тяжело обнаружить, так как они сродни бомбе замедленного действия: могут дать о себе знать далеко не сразу. Статический анализатор PVS-Studio облегчает задачу поиска и исправления подобных ошибок. Однако в этом направлении были сделаны ещё несколько шагов: недавно были более внимательно пересмотрены 64-битные диагностики, вследствие чего их распределение по уровням важности изменилось. Речь в данной статье пойдёт об этих изменениях, а также о том, как это повлияло на работу с инструментом и на поиск ошибок. Примеры 64-битных ошибок из реальных приложений прилагаются.
Для начала хотелось бы внести конкретики по содержанию. В статье раскрываются следующие темы:
Первый пункт говорит сам за себя: в нём будут рассмотрены основные изменения PVS-Studio, касающиеся анализа 64-битных ошибок, а также то, как они отразятся на работе с инструментом.
Второй, основной раздел, посвящён найденным 64-битным ошибкам в реальных проектах. Помимо фрагментов кода из проектов также будут приведены комментарии к ним, так что, возможно, вам удастся почерпнуть что-то новое для себя.
В третьем разделе осуществляется сравнение в эффективности поиска этих ошибок статическим анализатором PVS-Studio и средствами среды Microsoft Visual Studio 2013. Причём, в случае Visual Studio, для поиска ошибок использовались как компилятор, так и статический анализатор.
Не стоит забывать, что здесь выписаны только некоторые ошибки. В реальном проекте их наверняка будет куда больше и они будут более разнообразными. В конце статьи предложны ссылки, которые более полно познакомят вас с миром 64-битных ошибок.
Не так давно мы внимательно просмотрели 64-битные диагностики и более аккуратно распределили их по уровням важности.
Теперь распределение 64-битных ошибок выглядит так:
Уровень 1. Критические ошибки, которые причиняют вред в любом приложении. Примером может служить хранение указателя в 32-битной переменной типа int. Если вы разрабатываете 64-битное приложение, вы обязательно должны изучить и исправить предупреждения первого уровня.
Уровень 2. Ошибки, которые как правило проявляют себя только в приложениях, обрабатывающие большие массивы данных. Пример - использование для индексации огромного массива переменной типа 'int'.
Уровень 3. Всё остальное. Как правило, эти предупреждения не актуальны. Однако, для некоторых приложений, та или иная диагностика может оказаться крайне полезной.
Таким образом, установив фильтрацию по 64-битным ошибкам первого уровня, вы получите список сообщений, указывающих на участки кода, которые с большей вероятностью являются ошибочными. Не стоит недооценивать эти предупреждения, так как последствия 64-битных ошибок могут быть самыми разными, но явно неприятными и часто неожиданными. Именно про них и пойдёт речь.
Насколько тяжело было бы обнаружить подобные ошибки без такого инструмента, как PVS-Studio, думаю, поймёте по мере прочтения статьи.
Необходимо внимательно следить за правильным использованием типов данных. С этого, пожалуй, и начнём.
LRESULT CSaveDlg::OnGraphNotify(WPARAM wParam, LPARAM lParam)
{
LONG evCode, evParam1, evParam2;
while (pME && SUCCEEDED(pME->GetEvent(&evCode,
(LONG_PTR*)&evParam1,
(LONG_PTR*)&evParam2, 0)))
{
....
}
return 0;
}
Предупреждения анализатора:
Для того, чтобы понять суть ошибки, необходимо взглянуть на типы переменных 'evParam1', 'evParam2', а также на объявление метода 'GetEvent':
virtual HRESULT STDMETHODCALLTYPE GetEvent(
/* [out] */ __RPC__out long *lEventCode,
/* [out] */ __RPC__out LONG_PTR *lParam1,
/* [out] */ __RPC__out LONG_PTR *lParam2,
/* [in] */ long msTimeout) = 0;
Как видно из сообщения анализатора, выполняется опасное явное приведение типа. Дело в том, что тип 'LONG_PTR' является 'memsize-типом', имеющим размер 32 бита на Win32 (модель данных ILP32) и 64 бита на архитектуре Win64 (модель данных LLP64). В то же время тип 'LONG' имеет размеры 32 бита на обеих архитектурах. Так как на 64-битной архитектуре вышеупомянутые типы имеют разный размер, возможна некорректная работа с объектами, на которые ссылаются эти указатели.
Продолжим тему опасных приведений типов. Взглянем на следующий код:
BOOL WINAPI TrackPopupMenu(
_In_ HMENU hMenu,
_In_ UINT uFlags,
_In_ int x,
_In_ int y,
_In_ int nReserved,
_In_ HWND hWnd,
_In_opt_ const RECT *prcRect
);
struct JABBER_LIST_ITEM
{
....
};
INT_PTR CJabberDlgGcJoin::DlgProc(....)
{
....
int res = TrackPopupMenu(
hMenu, TPM_RETURNCMD, rc.left, rc.bottom, 0, m_hwnd, NULL);
....
if (res) {
JABBER_LIST_ITEM *item = (JABBER_LIST_ITEM *)res;
....
}
....
}
Предупреждение анализатора: V204 Explicit conversion from 32-bit integer type to pointer type: (JABBER_LIST_ITEM *) res test.cpp 57
Для начала неплохо было бы взглянуть на использовавшуюся в данном коде функцию 'TrackPopupMenu'. Она возвращает идентификатор выбранного пользователем элемента меню, или нулевое значение в случае ошибки или если выбора не было. Тип 'BOOL' для этих целей явно выбран неудачно, но что делать.
Результат выполнения данной функции, как это видно из кода, заносится в переменную 'res'. В случае, если какой-то элемент пользователем всё же был выбран (res!=0), то данная переменная приводится к типу указателя на структуру. Интересный подход, но так как в статье мы ведём беседу про 64-битные ошибки, давайте подумаем, как этот код будет исполняться на 32 и 64-битных архитектурах, и в чём может быть проблема?
Загвоздка в том, что на 32-битной архитектуре такие преобразования допустимы и осуществимы, так как типы 'pointer' и 'BOOL' имеют одинаковый размер. Но грабли дадут о себе знать на 64-битной архитектуре. В Win64 приложениях вышеупомянутые типы имеют разный размер (64 и 32 бита соответственно). Потенциальная ошибка состоит в том, что могут быть потеряны значения старших бит в указателе.
Продолжаем обзор. Фрагмент кода:
static int hash_void_ptr(void *ptr)
{
int hash;
int i;
hash = 0;
for (i = 0; i < (int)sizeof(ptr) * 8 / TABLE_BITS; i++)
{
hash ^= (unsigned long)ptr >> i * 8;
hash += i * 17;
hash &= TABLE_MASK;
}
return hash;
}
Предупреждение анализатора: V205 Explicit conversion of pointer type to 32-bit integer type: (unsigned long) ptr test.cpp 76
Разберемся, в чём же заключается проблема приведения переменной типа 'void*' к типу 'unsigned long' в этой функции. Как уже говорилось, данные типы имеют различный размер в модели данных LLP64, где тип 'void*' занимает 64 бита, а 'unsigned long' - 32 бита. В результате этого будут отсечены (потеряны) старшие биты, содержавшиеся в переменной 'ptr'. Значение же переменной 'i' по мере прохождения итераций увеличивается, как следствие этого - побитовый сдвиг вправо по мере прохождения итераций будет затрагивать всё большее количество бит. Так как размер переменной 'ptr' был усечён, с некоторой итерации все содержащиеся в ней биты будут заполняться 0. Как итог всего вышеописанного - на Win64-приложениях 'hash' будет составляться некорректно. За счёт заполнения 'hash' нулями, возможно возникновение коллизий, то есть получение одинаковых хешей для различных входных данных (в данном случае - указателей). В результате это может привести к неэффективной работе программы. Если бы выполнялось приведение к 'memsize-типу', усечения не произошло бы, и тогда сдвиг (а следовательно - составление хеша) осуществлялся бы корректно.
Посмотрим на следующий код:
class CValueList : public CListCtrl
{
....
public:
BOOL SortItems(_In_ PFNLVCOMPARE pfnCompare,
_In_ DWORD_PTR dwData);
....
};
void CLastValuesView::OnListViewColumnClick(....)
{
....
m_wndListCtrl.SortItems(CompareItems, (DWORD)this);
....
}
Предупреждение анализатора: V220 Suspicious sequence of types castings: memsize -> 32-bit integer -> memsize. The value being cast: 'this'. test.cpp 87
Диагностика V220 сигнализирует о двойном опасном преобразовании данных. В начале переменная 'memsize-типа' превращается в 32-битное значение, а затем сразу расширяется обратно до 'memsize-типа'. Фактически, это означает что будут "отрезаны" значения старших бит. Почти всегда это ошибка.
Продолжим раскрывать тему опасных преобразований:
#define YAHOO_LOGINID "yahoo_id"
DWORD_PTR __cdecl CYahooProto::GetCaps(int type, HANDLE /*hContact*/)
{
int ret = 0;
switch (type)
{
....
case PFLAG_UNIQUEIDSETTING:
ret = (DWORD_PTR)YAHOO_LOGINID;
break;
....
}
return ret;
}
Предупреждение анализатора: V221 Suspicious sequence of types castings: pointer -> memsize -> 32-bit integer. The value being cast: '"yahoo_id"'. test.cpp 99
Заметил тенденцию, что с каждым примером преобразований становится больше и больше. Тут их целых 3. И 2 из них являются опасными, по тем же причинам, что и все, описанные выше. Так как 'YAHOO_LOGINID' является строковым литералом, его тип - 'const char*', имеющий в 64-битной архитектуре тот же размер, что и тип 'DWORD_PTR', так что явное преобразование корректно. Но вот дальше начинаются нехорошие вещи. Тип 'DWORD_PTR' неявно приводится к целочисленному 32-битному. Но это не всё. Так как возвращаемый функцией результат имеет тип 'DWORD_PTR', будет выполнено ещё одно неявное преобразование, на этот раз обратно к 'memsize-типу'. Очевидно, что в таком случае использование возвращённого значения ведётся на свой страх и риск.
Хочу отметить, что компилятор Visual Studio 2013 выдал предупреждение следующего вида:
warning C4244: '=' : conversion from 'DWORD_PTR' to 'int', possible loss of data
Здесь будет возможен актуальный вопрос: почему предупреждение, выданное Visual Studio 2013, приведено только в этом примере? Вопрос справедливый, но наберитесь терпения, об этом будет написано ниже.
А пока продолжим рассматривать ошибки. Рассмотрим следующий код, содержащий иерархию классов:
class CWnd : public CCmdTarget
{
....
virtual void WinHelp(DWORD_PTR dwData, UINT nCmd = HELP_CONTEXT);
....
};
class CFrameWnd : public CWnd
{
....
};
class CFrameWndEx : public CFrameWnd
{
....
virtual void WinHelp(DWORD dwData, UINT nCmd = HELP_CONTEXT);
....
};
Предупреждение анализатора: V301 Unexpected function overloading behavior. See first argument of function 'WinHelpA' in derived class 'CFrameWndEx' and base class 'CWnd'. test.cpp 122
Пример интересен тем, что взят из отчёта при проверке библиотек Visual C++ 2012. Как видите, даже разработчики Visual C++ допускают 64-битные ошибки.
Достаточно подробно об этой ошибке написано в соответствующей статье. Здесь же я хотел объяснить суть вкратце. На 32-битной архитектуре данный код будет корректно отрабатываться, так как типы 'DWORD' и 'DWORD_PTR' имеют одинаковый размер, в классе-наследнике данная функция будет переопределена, и код будет выполняться корректно. Но подводный камень никуда не делся и даст знать о себе на 64-битной архитектуре. Так как в этом случае типы 'DWORD' и 'DWORD_PTR' будут иметь разные размеры, полиморфизм будет разрушен. Мы будем иметь на руках 2 разные функции, что идёт вразрез с тем, что подразумевалось.
И последний пример:
void CSymEngine::GetMemInfo(CMemInfo& rMemInfo)
{
MEMORYSTATUS ms;
GlobalMemoryStatus(&ms);
_ultot_s(ms.dwMemoryLoad, rMemInfo.m_szMemoryLoad,
countof(rMemInfo.m_szMemoryLoad), 10);
....
}
Предупреждение анализатора: V303 The function 'GlobalMemoryStatus' is deprecated in the Win64 system. It is safer to use the 'GlobalMemoryStatusEx' function. test.cpp 130
В принципе, особых пояснений не требуется, всё понятно из сообщения анализатора. Необходимо использовать функцию 'GlobalMemoryStatusEx', так как функция 'GlobalMemoryStatus' может работать некорректно на 64-битной архитектуре. Подробнее об этом написано на портале MSDN в описании соответствующей функции.
Примечание.
Обратите внимание на то, что все приведённые ошибки могут встретиться в самом обыкновенном прикладном программном обеспечении. Чтобы они возникли, программе вовсе не обязательно работать с большим объемом памяти. И именно поэтому диагностики, выявляющие эти ошибки, относятся к первому уровню.
Прежде чем рассказывать про результаты проверки статического анализатора среды Visual Studio 2013, хотелось бы остановиться на предупреждениях компилятора. Внимательные читатели наверняка заметили, что в тексте было приведено только 1 такое предупреждение. В чём же дело, спросите вы? А дело в том, что больше никаких предупреждений, как-то связанных с 64-битными ошибками, попросту не было. И это при 3-м уровне выдачи предупреждений.
Но стоит скомпилировать этот пример при всех включённых предупреждениях (EnableAllWarnings), как получаем...
Причём, совершенно неожиданно, предупреждения ведут в заголовочные файлы (например, winnt.h). Если не полениться и найти в этой куче предупреждений те, которые относятся к проекту, то всё же можно извлечь что-то интересное, например:
warning C4312: 'type cast' : conversion from 'int' to 'JABBER_LIST_ITEM *' of greater size
warning C4311: 'type cast' : pointer truncation from 'void *' to 'unsigned long'
warning C4311: 'type cast' : pointer truncation from 'CLastValuesView *const ' to 'DWORD'
warning C4263: 'void CFrameWndEx::WinHelpA(DWORD,UINT)' : member function does not override any base class virtual member function
В общем компилятор выдал 10 предупреждений в файле с этими примерами. Только 3 предупреждения из этого списка явно указывают на 64-битные ошибки (предупреждения компилятора С4311 и С4312). Среди этих предупреждений есть и такие, которые указывают на сужающее преобразование типов (С4244) или же на то, что виртуальная функция не будет переопределена (С4263). Эти предупреждения также косвенно указывают на 64-битные ошибки.
В итоге, исключив так или иначе повторяющие друг друга предупреждения, получим 5 предупреждений, касающихся рассматриваемых нами 64-битных ошибок.
Как видим, компилятору Visual Studio не удалось обнаружить все 64-битные ошибки. Напоминаю, что анализатор PVS-Studio в том же файле нашёл 9 ошибок первого уровня.
"А как же статический анализатор, встроенный в Visual Studio 2013?" - спросите вы. Может быть он справился лучше и нашёл больше ошибок? Давайте посмотрим.
Результатом проверки этих примеров статическим анализатором, встроенным в среду Visual Studio 2013 стали 3 предупреждения:
Но мы ведь смотрим 64-битные ошибки, верно? Сколько ошибок из этого списка относится к 64-битным? Только последняя (использование функции, которая может возвращать некорректные результаты).
Выходит, что статический анализатор Visual Studio 2013 нашёл 1 64-битную ошибку против 9, найденных анализатором PVS-Studio. Впечатляет, не правда ли? Представьте, какова будет разница в больших проектах.
А теперь ещё раз хочу напомнить, что по функциональности в плане обнаружения ошибок статические анализаторы кода, встроенные в среды Visual Studio 2013 и Visual Studio 2015 одинаковы (о чём подробнее написано в соответствующей заметке).
Нагляднее всего будет отразить результаты проверки примеров кода в виде таблицы.
Таблица 1. Результат поиска 64-битных ошибок анализатором PVS-Studio и средствами Microsoft Visual Studio 2013
Как видно из таблицы, с помощью PVS-Studio было обнаружено 9 64-битных ошибок, а общими средствами Microsoft Visual Studio 2013 - 6. Возможно, вы скажите, что не такая уж большая разница. Не соглашусь. Давайте прикинем, почему:
Как видно из вышенаписанного, для того, чтобы обнаружить 64-битные ошибки, найденные средствами Microsoft Visual Studio 2013, нужно проделать ещё определённый объём работы. А теперь представьте, насколько он увеличится, будь это реальный, действительно большой проект.
Что с PVS-Studio? Запускаем диагностику, выставляем фильтрацию по 64-битным ошибкам и нужным предупреждениям несколькими кликами мыши, получаем результат.
Надеюсь, что мне удалось показать, что перенос приложений на 64-битную архитектуру связан с рядом сложностей. Ошибки, подобные тем, что были описаны в этой статье, достаточно легко допустить, но при этом крайне тяжело найти. Добавим к этому тот факт, что не все подобные ошибки обнаруживаются средствами Microsoft Visual Studio 2013, да и для поиска тех нужно проделать определённый объём работ. В то же время статический анализатор PVS-Studio справился с поставленной задачей, показав достойный результат. При этом сам процесс поиска и фильтрации ошибок проще и удобнее. Согласитесь, что в действительно больших проектах без подобного инструмента пришлось бы туго, так что в подобных случаях хороший статический анализатор попросту необходим.
Разрабатываете 64-битное приложение? Скачайте триал PVS-Studio, проверьте свой проект и посмотрите, сколько 64-битных сообщений первого уровня у вас будет. Если несколько всё же обнаружатся - пожалуйста, исправьте их, и сделайте этот мир чуточку лучше.
Как и обещал, привожу перечень дополнительных материалов по теме 64-битных ошибок:
0