Семь шагов переноса программы в 64-битную систему - Выявление скрытых ошибок

ОГЛАВЛЕНИЕ

6. Выявление скрытых ошибок

Если вы считаете, что после исправления всех ошибок компиляции получите долгожданное 64-битное приложение, то ошибаетесь. Самое трудное еще впереди. На этапе компиляции исправляются самые явные ошибки, которые компилятор смог обнаружить, и которые в основном связаны с невозможностью неявного преобразования типов. Но это лишь малая часть проблемы. Большинство ошибок скрыто. С позиции абстрактного языка C++ эти ошибки кажутся безопасными и скрыты под явными преобразованиями типов. Число таких ошибок намного превышает число ошибок, выявленных на этапе компиляции.

Не возлагайте надежды на ключ /Wp64. Этот ключ часто представляется как чудесное средство поиска 64-битных ошибок. На практике ключ /Wp64 позволяет получить некоторые предупреждения, касающиеся неправильности некоторых участков кода в 64-битном режиме при компиляции 32-битного кода. При компиляции 64-битного кода эти предупреждения показываются в любом случае. И поэтому ключ /Wp64 игнорируется при компиляции 64-битного приложения. Данный ключ не поможет в поиске скрытых ошибок.

Рассмотрим несколько примеров скрытых ошибок.

6.1. Явное преобразование типа

Простейший, но отнюдь не самый легкий для выявления класс ошибок связан с явными преобразованиями типов, когда обрезаются значимые биты. Известный пример – преобразование указателей в 32-битные типы при передаче их в такие функции, как SendMessage(послать сообщение):

MyObj* pObj = ...
::SendMessage(hwnd, msg, (WORD)x, (DWORD)pObj);

Здесь явное преобразование типа используется для преобразования указателя в числовой тип. Для 32-битной архитектуры этот пример правилен, так как последний параметр функции SendMessage имеет тип LPARAM, совпадающий с DWORD в 32-битной архитектуре. Для 64-битной архитектуры DWORD неправилен и должен быть заменен на LPARAM. Тип LPARAM имеет размеры 32 или 64 бита в зависимости от архитектуры.

Это простой случай, но преобразование типа часто кажется более сложным, и его нельзя обнаружить с помощью предупреждений компилятора или поиска по тексту программы. Явные преобразования типов подавляют выявление ошибок компилятором, так как они предназначены для этой самой цели – сообщать компилятору, что преобразование типа верное, и что программист отвечает за безопасность кода. Явный поиск также не поможет. Типы могут иметь нестандартные имена (определенные программистом с помощью typedef), и велико количество методов, выполняющих явное преобразование типов. Для надежного обнаружения таких ошибок используйте только специальный инструментарий, такой как анализаторы Viva64 или PC-Lint.

6.2. Неявное преобразование типа

Следующий пример касается неявного преобразования типа, когда значимые биты также теряются. Код функции fread производит чтение из файла, но работает неверно, когда пытается считать более 2 ГБ в 64-битной системе.

size_t __fread(void * __restrict buf, 
    size_t size, size_t count, FILE * __restrict fp);

size_t fread(void * __restrict buf, size_t size, size_t count,
    FILE * __restrict fp)
{
    int ret;
    FLOCKFILE(fp);
    ret = __fread(buf, size, count, fp);
    FUNLOCKFILE(fp);
    return (ret);
}

Функция __fread возвращает тип size_t, но тип int используется для хранения количества считанных байтов. В результате при больших размерах считанных данных функция может вернуть неверное количество байтов.

Можно сказать, что это безграмотный код для начинающих, что компилятор сообщил о таком преобразовании типа, и что на самом деле данный код легко найти и исправить. Это в теории. На практике в крупных проектах все иначе. Этот пример взят из исходного кода FreeBSD. Ошибка была исправлена лишь в декабре 2008! Заметьте, что первая (пробная) 64-битная версия FreeBSD была выпущена в июне 2003.

Исходный код до исправления:

http://www.freebsd.org/cgi/cvsweb.cgi/src/lib/libc/stdio/fread.c?rev=1.14

Исправленный вариант кода (декабрь 2008):

http://www.freebsd.org/cgi/cvsweb.cgi/src/lib/libc/stdio/fread.c?rev=1.15

6.3. Биты и сдвиги

Легко сделать ошибку в коде при работе с отдельными битами. Следующий тип ошибки относится к операциям сдвига.

Пример ниже:

ptrdiff_t SetBitN(ptrdiff_t value, unsigned bitNum) {
  ptrdiff_t mask = 1 << bitNum;
  return value | mask;
}

Этот код успешно работает в 32-битной архитектуре и позволяет установить биты с номерами 0 - 31 на единицу. После переноса программы на 64-битную платформу вам понадобится установить биты от 0 до 63. Но этот код никогда не установит биты 32-63. Обратите внимание, что "1" имеет тип int, и когда совершается сдвиг на 32 разряда, происходит переполнение, как показано на рисунке 5. Будет ли получен 0 (рисунок 5-B) или 1 (рисунок 5-C) в результате –  зависит от реализации компилятора.

 

Рисунок 5. A – Правильная установка 32-го бита в 32-битном коде; B,C – ошибка установки 32-го бита в 64-битной системе (два способа поведения)

Чтобы исправить код, надо сделать константу "1" такого же типа, что и переменная mask(маска):

ptrdiff_t mask = ptrdiff_t(1) << bitNum;

Также обратите внимание, что неверный код вызывает еще одну ошибку. При установке 31 бита в 64-битной системе результатом функции будет значение 0xffffffff80000000 (смотрите рисунок 6). Результатом выражения 1 << 31 является отрицательное число --- 2147483648. В 64-битной целой переменной это число представлено как 0xffffffff80000000.

Рисунок 6. Ошибка установки 31-го бита в 64-битной системе

6.4. Магические числа

Магические константы, т.е. числа, с помощью которых определяется размер того или иного типа, могут вызывать много затруднений. Правильное решение – использовать операторы sizeof() для этих целей, но в большой программе может быть скрыт старый участок кода, где, как программисты считают, размер указателя равен 4 байтам, а в size_t всегда входит 32 бита. Обычно такие ошибки выглядят так:

size_t ArraySize = N * 4;
size_t *Array = (size_t *)malloc(ArraySize);

Рисунок 4 показывает основные числа, с которыми надо работать осторожно при переносе на 64-битную платформу.

Таблица 4. Основные магические значения, представляющие опасность при переносе приложений с 32-битной платформы на 64-битную

6.5. Ошибки, касающиеся использования 32-битных переменных в качестве индексов

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

const size_t size = ...;
char *array = ...;
char *end = array + size;
for (unsigned i = 0; i != size; ++i)
{
  const int one = 1;
  end[-i - one] = 0;
}

Первая ошибка заключается в том, что если объем обрабатываемых данных превышает 4 GB (0xFFFFFFFF), может возникнуть бесконечный цикл, так как переменная 'i' имеет беззнаковый тип и никогда не достигнет значения 0xFFFFFFFF. Это может произойти, но не обязательно. Это зависит от того, какой код собирает компилятор. Например, в режиме отладки бесконечный цикл будет иметься, а в коде выпуска не будет цикла, поскольку компилятор решит оптимизировать код с помощью 64-битного регистра для счетчика, и цикл будет правильным. Все это создает путаницу, и код, работавший вчера, может не работать сегодня.

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

Согласно правилам C++ выражение <-i - единица> в 32-битной системе вычисляется следующим образом: (на первом шаге i = 0):
•    выражение '-i' имеет беззнаковый тип и значение 0x00000000u.
•    переменная 'one' будет увеличена с типа 'int' до беззнакового типа и будет равняться 0x00000001u. Примечание: тип int увеличивается (согласно стандарту C++) до беззнакового типа, если участвует в операции, где второй аргумент имеет беззнаковый тип.

Совершается операция вычитания, в которой участвуют два значения беззнакового типа, и результат операции равен 0x00000000u - 0x00000001u = 0xFFFFFFFFu. Результат имеет беззнаковый тип.

В 32-битной системе обращение к массиву по индексу 0xFFFFFFFFu тождественно использованию индекса -1. То есть конец[0xFFFFFFFFu] является аналогом конец[-1]. В результате элементы массива обрабатываются правильно.
В 64-битной системе ситуация будет отличаться в последнем пункте. Беззнаковый тип будет увеличен до знакового типа ptfdiff_t, и индекс массива будет равняться 0x00000000FFFFFFFFi64. В результате произойдет переполнение.
Чтобы исправить код, надо использовать типы ptrdiff_t и size_t.

6.6. Ошибки, касающиеся изменения типов используемых функций

Есть ошибки, в которых никто не виноват, но это все же ошибки. Допустим, давно в Visual Studio 6.0 был разработан проект, содержавший класс CSampleApp - потомок CWinApp. В базовом классе есть виртуальная функция WinHelp. Потомок перекрывает эту функцию и выполняет все необходимые действия. Данный процесс показан на рисунке 7.

Рисунок 7. Эффективный правильный код, созданный в Visual Studio 6.0.

Затем проект переносится на Visual Studio 2005, где прототип функции WinHelp изменился, но этого никто не заметит, так как в 32-битном режиме типы DWORD и DWORD_PTR совпадают, и программа продолжает работать правильно (рисунок 8).

Рисунок 8. Неправильный, но эффективный 32-битный код.

Ошибка возникает в 64-битной системе, где размеры типов DWORD и DWORD_PTR различаются (рисунок 9). В 64-битном режиме классы кажутся содержащими две разные функции WinHelp, что неверно. Помните, что такие ловушки могут скрываться не только в MFC(библиотека базовых классов Microsoft), в которой изменились типы аргументов ряда функций, но и в коде ваших приложений и сторонних библиотек.

Рисунок 9. Ошибка возникает в 64-битном коде

6.7. Выявление скрытых ошибок

Есть много примеров таких 64-битных ошибок. Те, кто интересуется данной темой и хочет узнать больше о таких ошибках, смотрите статью (<) “20 проблем переноса кода C++ на 64-битную платформу”.

Этап поиска скрытых ошибок – весьма непростая задача, и многие из них возникают нерегулярно и лишь при больших входных данных. Статические анализаторы кода годятся для выявления таких ошибок, так как они могут проверить весь код приложения независимо от входных данных и частоту выполнения его участков в реальных условиях. Есть смысл в использовании статического анализа на этапе переноса приложения на 64-битные платформы для обнаружения большинства ошибок в самом начале и при дальнейшей разработке 64-битных решений. Статический анализ предупреждает и учит программиста лучше понимать особенности ошибок, относящихся к 64-битной архитектуре, и писать более эффективный код. Автор статьи является разработчиком одного из таких специализированных анализаторов кода под названием Viva64. Чтобы узнать больше об инструменте и скачать демоверсию, посетите сайт компании OOO "Program Verification Systems".

Справедливости ради надо сказать, что анализаторы кода Gimpel PC-Lint и Parasoft C++Test имеют наборы правил для диагностики 64-битных ошибок. Но, во-первых, это универсальные анализаторы, и правила диагностики 64-битных ошибок в них неполноценные. Во-вторых, они рассчитаны преимущественно на модель данных LP64, применяемую в семействе операционной системы Linux, и оттого они не особо полезны для программ Windows, где применяется модель данных LLP64.