>
>
>
Коллекция примеров 64-битных ошибок в р…

Андрей Карпов
Статей: 671

Коллекция примеров 64-битных ошибок в реальных программах

Статья представляет собой наиболее полную коллекцию примеров 64-битных ошибок на языках Си и Си++. Статья ориентирована на разработчиков Windows-приложений, использующих Visual C++, но будет полезна и более широкой аудитории.

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

Введение

Наша компания ООО "Системы программной верификации" занимается разработкой специализированного статического анализатора Viva64 выявляющего 64-битные ошибки в коде приложений на языке Си/Си++. В ходе этой работы наша коллекция примеров 64-битных дефектов постоянно пополняется, и мы решили собрать в этой статье наиболее интересные на наш взгляд ошибки. В статье приводятся примеры как взятые непосредственно из кода реальных приложений, так и составленные синтетически на основе реального кода, так как в нем они слишком "растянуты".

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

Также вы можете познакомиться с демонстрационной версией инструмента PVS-Studio, в состав которой входит статический анализатор кода Viva64, выявляющий практически все описанные в статье ошибки. Демонстрационная версия доступна для скачивания.

Пример 1. Переполнение буфера

struct STRUCT_1
{
  int *a;
};

struct STRUCT_2
{
  int x;
};
...
STRUCT_1 Abcd;
STRUCT_2 Qwer;
memset(&Abcd, 0, sizeof(Abcd));
memset(&Qwer, 0, sizeof(Abcd));

В программе объявлены два объекта типа STRUCT_1 и STRUCT_2, которые перед началом использования необходимо очистить (инициализировать все поля нулями). Реализуя инициализацию, программист решил скопировать похожу строчку и заменил в ней "&Abcd" на "&Qwer". Но при этом он забыл заменить "sizeof(Abcd)" на "sizeof(Qwer)".По удачному стечению обстоятельств размер структур STRUCT_1 и STRUCT_2 совпадал в 32-битной системе и код корректно работал долгое время.

При переносе кода на 64-битную систему размер структуры Abcd увеличился и как следствие возникла ошибка переполнения буфера (см. рисунок 1).

Рисунок 1 – Схематичное пояснение примера переполнения буфера

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

Пример 2. Лишние приведения типов

char *buffer;
char *curr_pos;
int length;
...
while( (*(curr_pos++) != 0x0a) && 
       ((UINT)curr_pos - (UINT)buffer < (UINT)length) );

Код плох, но это реальный код. Его задача состоит в поиске конца строки, обозначенного символом 0x0A. Код не будет работать со строками длиннее INT_MAX символов, так как переменная length имеет тип int. Однако нас интересует другая ошибка, поэтому будем считать, что программа работает с небольшим буфером и использование типа int корректно.

Проблема в том, что в 64-битной системе указатели buffer и curr_pos могут лежать за пределами первых 4 гигабайт адресного пространства. В этом случае явное приведение указателей к типу UINT отбросит значащие биты, и работа алгоритма будет нарушена (см. рисунок 2).

Рисунок 2 – Некорректны вычисления при поиске терминального символа

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

while(curr_pos - buffer < length && *curr_pos != '\n')
  curr_pos++;

Пример 3. Некорректные #ifdef

Часто в программах с длинной историей можно встретить участки кода, обернутые в конструкции #ifdef - -#else - #endif. При переносе программ на новую архитектуру, некорректно написанные условия могут привести к компиляции не тех фрагментов кода, как это планировалось разработчиками в прошлом (см. рисунок 3). Пример:

#ifdef _WIN32 // Win32 code
  cout << "This is Win32" << endl;
#else         // Win16 code
  cout << "This is Win16" << endl;
#endif

//Альтернативный некорректный вариант:
#ifdef _WIN16 // Win16 code
  cout << "This is Win16" << endl;
#else         // Win32 code
  cout << "This is Win32" << endl;
#endif

Рисунок 3 – Два варианта - это слишком мало

Полагаться на вариант #else в подобных ситуациях опасно. Лучше явно рассмотреть поведение для каждого случая (см. рисунок 4), а в ветку #else поместить сообщение об ошибке компиляции:

#if   defined _M_X64 // Win64 code (Intel 64)
  cout << "This is Win64" << endl;
#elif defined _WIN32 // Win32 code
  cout << "This is Win32" << endl;
#elif defined _WIN16 // Win16 code
  cout << "This is Win16" << endl;
#else
  static_assert(false, "Неизвестная платформа");
#endif

Рисунок 4 – Проверяются все возможные пути компиляции

Пример 4. Путаница с int и int*

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

int GlobalInt = 1;

void GetValue(int **x)
{
  *x = &GlobalInt;
}

void SetValue(int *x)
{
  GlobalInt = *x;
}

...
int XX;
GetValue((int **)&XX);
SetValue((int *)XX);

В данном примере переменная XX используется в качестве буфера для хранения указателя. Этот код будет корректно работать в тех 32-битных системах, где размер указателя совпадает с размером типа int. В 64-битном системе этот код некорректен и вызов

GetValue((int **)&XX);

приведет к порче 4 байт памяти рядом с переменной XX (см. рисунок 5).

Рисунок 5 – Порча памяти рядом с переменной XX

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

Исправление ошибки элементарно и заключается в выборе правильного типа для переменной XX. При этом перестает быть необходимым явное приведение типа:

int *XX;
GetValue(&XX);
SetValue(XX);

Пример 5. Использование устаревших функций

Ряд API-функций, хотя и оставлен для совместимости, представляет собой опасность при разработке 64-битных приложений. Классическим примером является использование таких функций как SetWindowLong и GetWindowLong. В программах можно встретить код, подобный следующему:

SetWindowLong(window, 0, (LONG)this);
...
Win32Window* this_window = (Win32Window*)GetWindowLong(window, 0);

Программиста, некогда написавшего этот код, не в чем упрекнуть. В ходе разработки, лет 5-10 назад, программист, опираясь на свой опыт и MSDN, составил код совершенно корректный с точки зрения 32-битной системы Windows. Прототип этих функций выглядит следующим образом:

LONG WINAPI SetWindowLong(HWND hWnd, int nIndex, LONG dwNewLong);
LONG WINAPI GetWindowLong(HWND hWnd, int nIndex);

То, что указатель явно приводится к типу LONG также оправдано, поскольку размер указателя и типа LONG совпадают в Win32 системах. Но думаю понятно, что при перекомпиляции программы в 64-битном варианте, данные приведения типа могут послужить причиной падения или неверной работы приложения.

Неприятность ошибки заключается в ее нерегулярном или даже крайне редком проявлении. Произойдет ошибка или нет, зависит от того, в какой области памяти создан объект, на который указывает указатель "this". Если объект создается в младших 4 гигабайтах адресного пространства, то 64-битная программа может корректно функционировать. Ошибка неожиданно может проявить себя через большой промежуток времени, когда из-за выделения памяти, объекты начнут создаваться за пределами первых четырех гигабайт.

В 64-битной системе использовать функции SetWindowLong/GetWindowLong можно только в том случае, если программа действительно сохраняет некие значения типа LONG, int, bool и подобные им. Если необходимо работать с указателями, то следует использовать расширенные варианты функций: SetWindowLongPtr/GetWindowLongPtr. Хотя, пожалуй, следует порекомендовать в любом случае использовать новые функции, чтобы не спровоцировать в будущем новых ошибок.

Примеры с функциями SetWindowLong и GetWindowLong являются классическими и приводятся практически во всех статьях посвященных разработке 64-битных приложений. Однако следует учесть, что этими функциями дело не ограничивается. Обратите внимания на: SetClassLong, GetClassLong, GetFileSize, EnumProcessModules, GlobalMemoryStatus (см. рисунок 6).

Рисунок 6 – Таблица с именами некоторых устаревших и современных функций

Пример 6. Обрезание значений при неявном приведении типов

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

bool Find(const ArrayOfStrings &arrStr)
{
  ArrayOfStrings::const_iterator it;
  for (it = arrStr.begin(); it != arrStr.end(); ++it)
  {
    unsigned n = it->find("ABC"); // Truncation
    if (n != string::npos)
      return true;
  }
  return false;
};

Приведенная функция ищет текст "ABC" в массиве строк и возвращает true, в случае если хотя бы одна строка содержит последовательность "ABC". При компиляции 64-битной версии кода, эта функция всегда будет возвращать true.

Константа "string::npos" в 64-битной системе имеет значение 0xFFFFFFFFFFFFFFFF типа size_t. При помещение этого значения в переменную "n" типа unsigned, происходит его обрезание до 0xFFFFFFFF. В результате условие " n != string::npos" всегда истинно, так как 0xFFFFFFFFFFFFFFFF не равно 0xFFFFFFFF (см. рисунок 7).

Рисунок 7 – Схематичное пояснение ошибки обрезания значения

Исправление элементарно, достаточно прислушаться к предупреждениям компилятора:

for (auto it = arrStr.begin(); it != arrStr.end(); ++it)
{
  auto n = it->find("ABC");
  if (n != string::npos)
    return true;
}
return false;

Пример 7. Необъявленные функции в Си

Несмотря на годы, программы или части программ, написанные на языке Си, остаются живее всех живых. Код этих программ гораздо более предрасположен к 64-битным ошибкам из-за менее строгих правил контроля типов в языке Си.

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

#include <stdlib.h>

void test()
{
  const size_t Gbyte = 1024 * 1024 * 1024;
  size_t i;
  char *Pointers[3];

  // Allocate
  for (i = 0; i != 3; ++i)
    Pointers[i] = (char *)malloc(Gbyte);

  // Use
  for (i = 0; i != 3; ++i)
    Pointers[i][0] = 1;

  // Free
  for (i = 0; i != 3; ++i)
    free(Pointers[i]);
}

Данный код корректно выделит память, запишет в первый элемент каждого массива по единице и освободит занятую память. Код совершенно корректно работает на 64-битной системе.

Теперь удалим или закомментируем строчку "#include <stdlib.h>". Код по-прежнему будет собираться, но при запуске программы произойдет ее аварийное завершение. Если заголовочный файл "stdlib.h" не подключен, компилятор языка Си считает, что функция malloc вернет тип int. Первые два выделения памяти, скорее всего, пройдут успешно. При третьем обращении функция malloc вернет адрес массива за пределами первых 2-х гигабайт. Поскольку компилятор считает, что результат работы функции имеет тип int, он неверно интерпретирует результат и сохраняет в массиве Pointers некорректное значение указателя.

Рассмотрим ассемблерный код, генерируемый компилятором Visual C++ для 64-битной Debug версии. Вначале приводится корректный код, который будет сгенерирован, когда присутствует объявление функции malloc (подключен файл "stdlib.h"):

Pointers[i] = (char *)malloc(Gbyte);
mov   rcx,qword ptr [Gbyte]
call  qword ptr [__imp_malloc (14000A518h)]
mov    rcx,qword ptr [i]
mov    qword ptr Pointers[rcx*8],rax

Теперь рассмотрим вариант некорректного кода, когда отсутствует объявление функции malloc:

Pointers[i] = (char *)malloc(Gbyte);
mov    rcx,qword ptr [Gbyte]
call   malloc (1400011A6h)
cdqe
mov    rcx,qword ptr [i]
mov    qword ptr Pointers[rcx*8],rax

Обратите внимание на наличие инструкции CDQE (Convert doubleword to quadword). Компилятор посчитал, что результат содержится в регистре eax и расширил его до 64-битного значения, чтобы записать в массив Pointers. Соответственно старшие биты регистра rax будут потеряны. Если даже адрес выделенной памяти лежит в пределах первых четырех гигабайт, в случае, когда старший бит регистра eax равен 1 мы все равно получим некорректный результат. Например, адрес 0x81000000 превратится в 0xFFFFFFFF81000000.

Пример 8. Останки динозавров в больших и старых программах

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

Рисунок 8 – Раскопки динозавра

Есть атавизмы связанные и с 64-битностью. Вернее атавизмы, препятствующие работе современного 64-битного кода. Рассмотрим пример:

// beyond this, assume a programming error
#define MAX_ALLOCATION 0xc0000000 

void *malloc_zone_calloc(malloc_zone_t *zone,
  size_t num_items, size_t size)
{
  void *ptr;
  ...

  if (((unsigned)num_items >= MAX_ALLOCATION) ||
      ((unsigned)size >= MAX_ALLOCATION) ||
      ((long long)size * num_items >=
       (long long) MAX_ALLOCATION))
  {  
    fprintf(stderr,
      "*** malloc_zone_calloc[%d]: arguments too large: %d,%d\n",
      getpid(), (unsigned)num_items, (unsigned)size);
    return NULL;
  }
  ptr = zone->calloc(zone, num_items, size);
  ...
  return ptr;
}

Во-первых, код функции содержит проверку на допустимые размеры выделяемой памяти, являющиеся странными для 64-битной системы. А во-вторых, выдаваемое диагностическое сообщение будет некорректно, поскольку если мы попросим выделить память под 4 400 000 000 элементов, из-за явного приведения типа к unsigned, нам будет выдано странное сообщение о невозможности выделения памяти всего лишь для 105 032 704 элементов.

Пример 9. Виртуальные функции

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

С незапамятных времен в библиотеке MFC есть класс CWinApp, в котором имеется функция WinHelp:

class CWinApp {
  ...
  virtual void WinHelp(DWORD dwData, UINT nCmd);
};

Для показа собственной справки в пользовательском приложении необходимо было эту функцию перекрыть:

class CSampleApp : public CWinApp {
  ...
  virtual void WinHelp(DWORD dwData, UINT nCmd);
};

И все было прекрасно до тех пор, пока не появились 64-битные системы. Разработчикам MFC пришлось поменять интерфейс функции WinHelp (и некоторых других функций) так:

class CWinApp {
  ...
  virtual void WinHelp(DWORD_PTR dwData, UINT nCmd);
};

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

Рисунок 9 – Ошибка, связанная с виртуальными функциями

Пример 10. Магические числа в качестве параметров

Магические числа, содержащиеся в теле программ, являются плохим стилем и провоцируют возникновение ошибок. В качестве примера магических чисел можно привести 1024 и 768, жестко обозначающие размер разрешения экрана. В рамках этой статьи нам интересны те магические числа, которые могут привести к проблемам в 64-битном приложении. Наиболее распространенные числа, опасные для 64-битных программ, представлены в таблице на рисунке 10.

Рисунок 10 – Магические числа опасные для 64-битных программ

Продемонстрируем пример работы с функцией CreateFileMapping, встретившийся в одной из CAD-систем:

HANDLE hFileMapping = CreateFileMapping(
  (HANDLE) 0xFFFFFFFF,
  NULL,
  PAGE_READWRITE,
  dwMaximumSizeHigh,
  dwMaximumSizeLow,
  name);

Вместо корректной зарезервированной константы INVALID_HANDLE_VALUE используется число 0xFFFFFFFF. Это некорректно в Win64 программе, где константа INVALID_HANDLE_VALUE принимает значение 0xFFFFFFFFFFFFFFFF. Правильным вариантом вызова функции будет:

HANDLE hFileMapping = CreateFileMapping(
  INVALID_HANDLE_VALUE,
  NULL,
  PAGE_READWRITE,
  dwMaximumSizeHigh,
  dwMaximumSizeLow,
  name);

Примечание. Некоторые считают, что значение 0xFFFFFFFF при расширении до указателя превращается в 0xFFFFFFFFFFFFFFFF. Это не так. Согласно правилам языка Си/Си++ значение 0xFFFFFFFF имеет тип "unsigned int", так как не может быть представлено типом "int". Соответственно, расширяясь до 64-битного типа, значение 0xFFFFFFFFu превращается в 0x00000000FFFFFFFFu. А вот если написать так (size_t)(-1), то мы получим ожидаемое 0xFFFFFFFFFFFFFFFF. Здесь "int" вначале расширяется до "ptrdiff_t", а затем превращается в "size_t".

Пример 11. Магические константы, обозначающие размер

Другой частой ошибкой является использование магических чисел для задания размера объекта. Рассмотрим пример выделения и обнуления буфера:

size_t count = 500;
size_t *values = new size_t[count];
// Будет заполнена только часть буфера
memset(values, 0, count * 4);

В данном случае в 64-битной системе выделяется больше памяти, чем затем заполняется нулевыми значениями (см. рисунок 11) . Ошибка заключается в предположении, что размер типа size_t всегда равен четырем байтам.

Рисунок 11 – Заполнение только части массива

Корректный вариант:

size_t count = 500;
size_t *values = new size_t[count];
memset(values, 0, count * sizeof(values[0]));

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

Пример 12. Переполнение стека

Во многих случаях 64-битная программа потребляет больше памяти и стека. Выделение большего количества памяти в куче опасности не представляет, так как этого вида памяти 64-битной программе доступно во много раз больше, чем 32-битной. А вот увеличение используемой стековой памяти может привести к его неожиданному переполнению (stack overflow).

Механизм использования стека отличается в различных операционных системах и компиляторах. Мы рассмотрим особенность использования стека в коде Win64 приложений, построенных компилятором Visual C++.

При разработке соглашений по вызовам (calling conventions) в Win64 системах решили положить конец существованию различных вариантов вызова функций. В Win32 существовал целый ряд соглашений о вызове: stdcall, cdecl, fastcall, thiscall и так далее. В Win64 только одно "родное" соглашение по вызовам. Модификаторы подобные __cdecl компилятором игнорируются.

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

RCX: 1-й целочисленный аргумент

RDX: 2-й целочисленный аргумент

R8: 3-й целочисленный аргумент

R9: 4-й целочисленный аргумент

Остальные целочисленные аргументы передаются через стек. Указатель "this" считается целочисленным аргументом, поэтому он всегда помещается в регистр RCX. Если передаются значения с плавающей точкой, то первые четыре из них передаются в регистрах XMM0-XMM3, а последующие - через стек.

Хотя аргументы могут быть переданы в регистрах, компилятор все равно резервирует для них место в стеке, уменьшая значение регистра RSP (указателя стека). Как минимум, каждая функция должна резервировать в стеке 32 байта (четыре 64-битных значения, соответствующие регистрам RCX, RDX, R8, R9). Это пространство в стеке позволяет легко сохранить содержимое переданных в функцию регистров в стеке. От вызываемой функции не требуется сбрасывать в стек входные параметры, переданные через регистры, но резервирование места в стеке при необходимости позволяет это сделать. Если передается более четырех целочисленных параметров, в стеке резервируется соответствующее дополнительное пространство.

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

Обратим внимание еще на один момент. Указатель стека RSP должен перед очередным вызовом функции быть выровнен по границе 16 байт. Таким образом, суммарный размер используемого стека при вызове в 64-битном коде функции без параметров составляет 48 байт: 8 (адрес возврата) + 8 (выравнивание) + 32 (резерв для аргументов).

Неужели все так плохо? Нет. Не следует забывать, что большее количество регистров имеющихся в распоряжении 64-битного компилятора, позволяют построить более эффективный код и не резервировать в стеке память под некоторые локальные переменные функций. Таким образом, в ряде случаев 64-битный вариант функции использует меньше стека, чем 32-битный вариант. Более подробно этот вопрос и различные примеры рассматриваются в статье "Причины, по которым 64-битные программы требуют больше стековой памяти".

Предсказать, будет потреблять 64-битная программа больше стека или меньше невозможно. В силу того, что Win64-программа может использовать в 2-3 раза больше стековой памяти, необходимо подстраховаться и изменить настройку проекта, отвечающую за размер резервируемого стека. Выберите в настройках проекта параметр Stack Reserve Size (ключ /STACK:reserve) и увеличьте размер резервируемого стека в три раза. По умолчанию этот размер составляет 1 мегабайт.

Пример 13. Функция с переменным количеством аргументов и переполнение буфера

Хотя использование функций с переменным количеством аргументов, таких как printf, scanf считается в Си++ плохим стилем, они по прежнему широко используются. Эти функции создают множество проблем при переносе приложений на другие системы, в том числе и на 64-битные системы. Рассмотрим пример:

int x;
char buf[9];
sprintf(buf, "%p", &x);

Автор кода не учел, что размер указателя в будущем может составить более 32 бит. В результате на 64-битной архитектуре данный код приведет к переполнению буфера (см. рисунок 12). Эту ошибку вполне можно отнести к использованию магического числа '9', но в реальном приложении переполнение буфера может возникнуть и без магических чисел.

Рисунок 12 – Переполнение буфера при работе с функцией sprintf

Варианты исправления данного кода различны. Рациональнее всего провести рефакторинг кода с целью избавиться от использования опасных функций. Например, можно заменить printf на cout, а sprintf на boost::format или std::stringstream.

Примечание. Эту рекомендацию часто критикуют разработчики под Linux, аргументируя тем, что gcc проверяет соответствие строки форматирования фактическим параметрам, передаваемым, например, в функцию printf. И, следовательно, использование printf безопасно. Однако они забывают, что строка форматирования может передаваться из другой части программы, загружаться из ресурсов. Другими словами, в реальной программе строка форматирования редко присутствует в явном виде в коде, и, соответственно, компилятор не может ее проверить. Если же разработчик использует Visual Studio 2005/2008/2010, то он не сможет получить предупреждение на код вида "void *p = 0; printf("%x", p);" даже используя ключи /W4 и /Wall.

Пример 14. Функция с переменным количеством аргументов и неверный формат

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

const char *invalidFormat = "%u";
size_t value = SIZE_MAX;
// Будет распечатано неверное значение
printf(invalidFormat, value);

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

// Здесь указатели сохранялись в виде строки
int *p1, *p2;
....
char str[128];
sprintf(str, "%X %X", p1, p2);

// В другой функции данная строка
// обрабатывалась следующим образом:
void foo(char *str)
{
  int *p1, *p2;
  sscanf(str, "%X %X", &p1, &p2);
  // Результат - некорректное значение указателей p1 и p2.
  ...
}

Формат "%X" не предназначен для работы с указателями и как следствие подобный код некорректен сточки зрения 64-битных систем. В 32-битных системах он вполне работоспособен, хотя и не красив.

Пример 15. Хранение в double целочисленных значений

Нам не приходилось самим встречать подобную ошибку. Вероятно, это ошибка редка, но вполне реальна.

Тип double, имеет размер 64-бита, и совместим со стандартом IEEE-754 на 32-битных и 64-битных системах. Некоторые программисты используют тип double для хранения и работы с целочисленными типами:

size_t a = size_t(-1);
double b = a;
--a;
--b;
size_t c = b; // x86: a == c
              // x64: a != c

Данный пример еще можно пытаться оправдывать на 32-битной системе, так как тип double имеет 52 значащих бита и способен без потерь хранить 32-битное целое значение. Но при попытке сохранить в double 64-битное целое число точное значение может быть потеряно (см. рисунок 13).

Рисунок 13 – Количество значащих битов в типах size_t и double

Пример 16. Адресная арифметика. A + B != A - (-B)

Адресная арифметика (address arithmetic) - это способ вычисления адреса какого-либо объекта при помощи арифметических операций над указателями, а также использование указателей в операциях сравнения. Адресную арифметику также называют арифметикой над указателями (pointer arithmetic).

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

Рассмотрим первую из ошибок данного типа:

char *A = "123456789";
unsigned B = 1;
char *X = A + B;
char *Y = A - (-B);
if (X != Y)
  cout << "Error" << endl;

Причина, по которой в Win32 программе A + B == A - (-B), показана на рисунке 14.

Рисунок 14 – Win32: A + B == A - (-B)

Причина, по которой в Win64 программе A + B != A - (-B), показана на рисунке 15.

Рисунок 15 – Win64: A + B != A - (-B)

Ошибка будет устранена, если использовать подходящий memsize-тип. В данном случае используется тип ptrdfiff_t:

char *A = "123456789";
ptrdiff_t B = 1;
char *X = A + B;
char *Y = A - (-B);

Пример 17. Адресная арифметика. Знаковые и беззнаковые типы

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

LONG p1[100];
ULONG x = 5;
LONG y = -1;
LONG *p2 = p1 + 50;
p2 = p2 + x * y;
*p2 = 1; // Access violation

Выражение "x * y" имеет значение 0xFFFFFFFB и имеет тип unsigned. Данный код, собранный в 32-битном варианте работоспособен, так как сложение указателя с 0xFFFFFFFB эквивалентно его уменьшению на 5. В 64-битной указатель после прибавления 0xFFFFFFFB начнет указывать далеко за пределы массива p1 (смотри рисунок 16).

Рисунок 16 – Выход за границы массива

Исправление заключается в использовании memsize-типов и аккуратной работе со знаковыми и беззнаковыми типами:

LONG p1[100];
LONG_PTR x = 5;
LONG_PTR y = -1;
LONG *p2 = p1 + 50;
p2 = p2 + x * y;
*p2 = 1; // OK

Пример 18. Адресная арифметика. Переполнения

class Region {
  float *array;
  int Width, Height, Depth;
  float Region::GetCell(int x, int y, int z) const;
  ...
};

float Region::GetCell(int x, int y, int z) const {
  return array[x + y * Width + z * Width * Height];
}

Код взят из реальной программы математического моделирования, в которой важным ресурсом является объем оперативной памяти, и возможность на 64-битной архитектуре использовать более 4 гигабайт памяти существенно увеличивает вычислительные возможности. В программах данного класса для экономии памяти часто используют одномерные массивы, осуществляя работу с ними как с трехмерными массивами. Для этого существуют функции, аналогичные GetCell, обеспечивающие доступ к необходимым элементам.

Приведенный код корректно работает с указателями, если значение выражения " x + y * Width + z * Width * Height" не превышает INT_MAX (2147483647). В противном случае произойдет переполнение, что приведет к неопределенному поведению в программы.

Такой код мог всегда корректно работать на 32-битной платформе. В рамках 32-битной архитектуры программе недоступен объем памяти для создания массива подобного размеров. На 64-битной архитектуре это ограничение снято, и размер массива легко может превысить INT_MAX элементов.

Программисты часто допускают ошибку, пытаясь исправить код следующим образом:

float Region::GetCell(int x, int y, int z) const {
  return array[static_cast<ptrdiff_t>(x) + y * Width +
               z * Width * Height];
}

Они знают, что по правилам языка Си++ выражение для вычисления индекса будет иметь тип ptrdiff_t и надеются за счет этого избежать переполнения. Но переполнение может произойти внутри подвыражения "y * Width" или "z * Width * Height", так как для их вычисления по-прежнему используется тип int.

Если вы хотите исправить код, не изменяя типов переменных, участвующих в выражении, то вы можете явно привести каждое подвыражение к типу ptrdiff_t:

float Region::GetCell(int x, int y, int z) const {
  return array[ptrdiff_t(x) +
               ptrdiff_t(y) * Width +
               ptrdiff_t(z) * Width * Height];
}

Другое, более верное решение - изменить типы переменных:

typedef ptrdiff_t TCoord;
class Region {
  float *array;
  TCoord Width, Height, Depth;
  float Region::GetCell(TCoord x, TCoord y, TCoord z) const;
  ...
};

float Region::GetCell(TCoord x, TCoord y, TCoord z) const {
  return array[x + y * Width + z * Width * Height];
}

Пример 19. Изменение типа массива

Иногда в программах для удобства изменяют тип массива при его обработке. Опасное и безопасное приведение типов представлено в следующем коде:

int array[4] = { 1, 2, 3, 4 };
enum ENumbers { ZERO, ONE, TWO, THREE, FOUR };

//safe cast (for MSVC)
ENumbers *enumPtr = (ENumbers *)(array);
cout << enumPtr[1] << " ";

//unsafe cast
size_t *sizetPtr = (size_t *)(array);
cout << sizetPtr[1] << endl;

//Output on 32-bit system: 2 2
//Output on 64-bit system: 2 17179869187

Как видите, результат вывода программы отличается в 32-битном и 64-битном варианте. На 32-битной системе доступ к элементам массива осуществляется корректно, так как размеры типов size_t и int совпадают, и мы видим вывод "2 2".

На 64-битной системе мы получили в выводе "2 17179869187", так как именно значение 17179869187 находится в 1-ом элементе массива sizetPtr (см. рисунок 17). В некоторых случаях именно такое поведение и бывает нужно, но обычно это является ошибкой.

Рисунок 17 – Представление элементов массивов в памяти

Примечание. Тип enum в компиляторе Visual C++ по умолчанию совпадает размером с типом int, то есть является 32-битным типом. Использование enum другого размера возможно только с помощью расширения, считающимся нестандартным в Visual C++. Поэтому приведенный пример корректен в Visual C++, но с точки зрения других компиляторов приведение указателя на элементы int к указателю на элементы enum может быть также некорректным.

Пример 20. Упаковка указателя в 32-битный тип

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

char *ptr = ...;
int n = (int) ptr;
...
ptr = (char *) n;

В 64-битной программе это некорректно, поскольку тип int остался 32-битным и не может хранить в себе 64-битный указатель. Часто это не удается заметить сразу. Благодаря стечению обстоятельств при тестировании указатель может всегда ссылаться на объекты, расположенные в младших 4 гигабайтах адресного пространства. В этом случае 64-битная программа будет удачно работать, и может неожиданно отказать только спустя большой промежуток времени (см. рисунок 18).

Рисунок 18 – Помещение указателя в переменную типа int

Если все же необходимо поместить указатель в переменную целочисленного типа, то следует использовать такие типы как intptr_t, uintptr_t, ptrdiff_t и size_t.

Пример 21. Memsize-типы в объединениях

Когда возникает необходимость работать с указателем как с целым числом, иногда удобно воспользоваться объединением, как показано в примере, и работать с числовым представлением типа без использования явных приведений:

union PtrNumUnion {
  char *m_p;
  unsigned m_n;
} u;

u.m_p = str;
u.m_n += delta;

Данный код корректен на 32-битных системах и некорректен на 64-битных. Изменяя член m_n на 64-битной системе, мы работаем только с частью указателя m_p (смотри рисунок 19).

Рисунок 19 – Представление объединения в памяти на 32-битной и 64-битной системе.

Следует использовать тип, который будет соответствовать размеру указателя:

union PtrNumUnion {
  char *m_p;
  uintptr_t m_n; //type fixed
} u;

Пример 22. Вечный цикл

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

size_t Count = BigValue;
for (unsigned Index = 0; Index != Count; Index++)
{ ... }

Это цикл никогда не прекратится, если значение Count > UINT_MAX. Предположим, что на 32-битных системах этот код работал с количеством итераций менее значения UINT_MAX. Но 64-битный вариант программы может обрабатывать больше данных, и ему может потребоваться большее количество итераций. Поскольку значения переменной Index лежат в диапазоне [0..UINT_MAX], то условие "Index != Count" никогда не выполнится, что и приводит к бесконечному циклу (см. рисунок 20).

Рисунок 20 – Механизм возникновения вечного цикла

Пример 23. Работа с битами и операция NOT

Работа с битовыми операциями требует особой аккуратности от программиста при разработке кроссплатформенных приложений, в которых типы данных могут иметь различные размеры. Поскольку перенос программы на 64-битную платформу также ведет к изменению размерности некоторых типов, то высока вероятность возникновения ошибок в участках кода, работающих с отдельными битами. Чаще всего это происходит из-за смешенной работы с 32-битными и 64-битными типами данных. Рассмотрим ошибку, возникшую в коде из-за некорректного применения операции NOT:

UINT_PTR a = ~UINT_PTR(0);
ULONG b = 0x10;
UINT_PTR c = a & ~(b - 1);
c = c | 0xFu;
if (a != c)
  cout << "Error" << endl;

Ошибка заключается в том, что маска, заданная выражением "~(b - 1)", имеет тип ULONG. Это приводит к обнулению старших разрядов переменной "a", ходя должны были обнулиться только младшие четыре бита (см. рисунок 21).

Рисунок 21 – Ошибка из-за обнуления старших бит

Исправленный вариант кода может выглядеть следующим образом:

UINT_PTR c = a & ~(UINT_PTR(b) - 1);

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

Пример 24. Работа с битами, сдвиги

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 позиции произойдет переполнение, как показано на рисунке 22. Получим мы в результате 0 (рисунок 22-B) или 1 (рисунок 22-C) - зависит от реализации компилятора.

Рисунок 22 – a) корректная установка 31-ого бита в 32-битном коде (биты считаются от 0); b,c) - Ошибка установки 32-ого бита на 64-битной системе (два варианта поведения, зависящих от компилятора)

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

ptrdiff_t mask = static_cast<ptrdiff_t>(1) << bitNum;

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

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

Пример 25. Работа с битами и знаковое расширение

Приведенная далее ошибка редка, но, к сожалению, достаточно сложна в понимании. Поэтому остановимся на ней чуть подробнее.

struct BitFieldStruct {
  unsigned short a:15;
  unsigned short b:13;
};

BitFieldStruct obj;
obj.a = 0x4000;
size_t x = obj.a << 17; //Sign Extension
printf("x 0x%Ix\n", x);
//Output on 32-bit system: 0x80000000
//Output on 64-bit system: 0xffffffff80000000

В 32-битной среде порядок вычисления выражения будет выглядеть, как показано на рисунке 24.

Рисунок 24 - Вычисление выражения в 32-битном коде

Обратим внимание, что при вычислении выражения "obj.a << 17" происходит знаковое расширение типа unsigned short до типа int. Более наглядно, это может продемонстрировать следующий код:

#include <stdio.h>

template <typename T> void PrintType(T)
{
  printf("type is %s %d-bit\n",
          (T)-1 < 0 ? "signed" : "unsigned", sizeof(T)*8);
}

struct BitFieldStruct {
  unsigned short a:15;
  unsigned short b:13;
};

int main(void)
{
  BitFieldStruct bf;
  PrintType( bf.a );
  PrintType( bf.a << 2);
  return 0;
}

Result:
type is unsigned 16-bit
type is signed 32-bit

Теперь посмотрим, к чему приводит наличие знакового расширения в 64-битном коде. Последовательность вычисления выражения показана на рисунке 25.

Рисунок 25 - Вычисление выражения в 64-битном коде

Член структуры obj.a преобразуется из битового поля типа unsigned short в int. Выражение "obj.a << 17" имеет тип int, но оно преобразуется в ptrdiff_t и затем в size_t, перед тем как будет присвоено переменной addr. В результате мы получим число значение 0xffffffff80000000, вместо ожидаемого значения 0x0000000080000000.

Будьте внимательны при работе с битовыми полями. Для предотвращения описанной ситуации в нашем примере достаточно явно привести obj.a к типу size_t.

...
size_t x = static_cast<size_t>(obj.a) << 17; // OK
printf("x 0x%Ix\n", x);
//Output on 32-bit system: 0x80000000
//Output on 64-bit system: 0x80000000

Пример 26. Сериализация и обмен данными

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

В основном, ошибки данного рода заключаются в сериализации memsize-типов и операциях обмена данными с их использованием:

size_t PixelsCount;
fread(&PixelsCount, sizeof(PixelsCount), 1, inFile);

Недопустимо использование типов, которые меняют свой размер в зависимости от среды разработки, в бинарных интерфейсах обмена данными. В языке Си++ большинство типов не имеют четкого размера и, следовательно, их все невозможно использовать для этих целей. Поэтому создатели средств разработки и сами программисты создают типы данных, имеющие строгий размер, такие как __int8, __int16, INT32, word64 и так далее.

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

Порядок байт - метод записи байтов многобайтовых чисел (см. рисунок 26). Порядок от младшего к старшему (англ. little-endian) - запись начинается с младшего и заканчивается старшим. Этот порядок записи принят в памяти персональных компьютеров с x86 и x86-64-процессорами. Порядок от старшего к младшему (англ. big-endian) - запись начинается со старшего и заканчивается младшим. Этот порядок является стандартным для протоколов TCP/IP. Поэтому, порядок байтов от старшего к младшему часто называют сетевым порядком байтов (англ. network byte order). Этот порядок байт используется процессорами Motorola 68000, SPARC.

Кстати, некоторые процессоры могут работать и в порядке от младшего к старшему, и наоборот. К их числу относится, например IA-64.

Рисунок 26 - Порядок байт в 64-битном типе на little-endian и big-endian системах

Разрабатывая бинарный интерфейс или формат данных, следует помнить о последовательности байт. А если 64-битная система, на которую Вы переносите 32-битное приложение, имеет иную последовательность байт, то вы просто будете вынуждены учесть это в своем коде. Для преобразования между сетевым порядком байт (big-endian) и порядком байт (little-endian), можно использовать функции htonl(), htons(), bswap_64, и так далее.

Пример 27. Изменение выравнивания типов

Помимо изменения размеров некоторых типов данных, ошибки могут возникать и из-за изменения правил их выравнивания в 64-битной системе (см. рисунок 27).

Рисунок 27 – Размеры типы и границы их выравнивания (значения точны для Win32/Win64, но могут варьироваться в "Unix-мире" и приводятся просто в качестве примера)

Рассмотрим пример описания проблемы, найденного в одном из форумов:

Столкнулся сегодня с одной проблемой в Linux. Есть структура данных, состоящая из нескольких полей: 64-битный double, потом 8 unsigned char и один 32-битный int. Итого получается 20 байт (8 + 8*1 + 4). Под 32-битными системами sizeof равен 20 и всё работает нормально. А под 64-битным Linux'ом sizeof возвращает 24. Т.е. идёт выравнивание по границе 64 бит.

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

Когда меняются размеры полей в структуре и из-за этого меняется сам размер структуры это понятно и привычно. Здесь другая ситуация. Размер полей остался прежний, но из-за иных правил выравнивания размер структуры все равно изменится (см. рисунок 28). Такое поведение может привести к разнообразным ошибкам, например в несовместимости форматов сохраняемых данных.

Рисунок 28 – Схематическое изображение структур и правил выравнивания типов

Пример 28. Выравнивания типов и почему нельзя писать sizeof(x) + sizeof(y)

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

struct MyPointersArray {
  DWORD m_n;
  PVOID m_arr[1];
} object;
...
malloc( sizeof(DWORD) + 5 * sizeof(PVOID) );
...

Этот код будет корректно работать в 32-битном варианте, но его 64-битный вариант даст сбой.

При выделении памяти, необходимой для хранения объекта типа MyPointersArray, содержащего 5 указателей, необходимо учесть, что начало массива m_arr будет выровнено по границе 8 байт. Расположение данных в памяти на разных системах (Win32/Win64) показано на рисунке 29.

Рисунок 29 – Расположение данных в памяти в 32-битной и 64-битной системе

Корректный расчет размера должен выглядеть следующим образом:

struct MyPointersArray {
  DWORD m_n;
  PVOID m_arr[1];
} object;
...
malloc( FIELD_OFFSET(struct MyPointersArray, m_arr) +
        5 * sizeof(PVOID) );
...

В приведенном коде мы узнаем смещение последнего члена структуры и суммируем это смещение с его размером. Смещение члена структуры или класса можно узнать с использованием макроса offsetof или FIELD_OFFSET. Всегда используйте эти макросы для получения смещения в структуре, не опираясь на свои предположения о размерах типов и правилах их выравнивания.

Пример 29. Перегруженные функции

При перекомпиляции программы может начать выбираться другая перегруженная функция (см. рисунок 30).

Рисунок 30 – Выбор перегруженной функции в 32-битной и 64-битной системе

Пример проблемы:

class MyStack {
...
public:
  void Push(__int32 &);
  void Push(__int64 &);
  void Pop(__int32 &);
  void Pop(__int64 &);
} stack;

ptrdiff_t value_1;
stack.Push(value_1);
...
int value_2;
stack.Pop(value_2);

Неаккуратный программист помещал и затем выбирал из стека значения различных типов (ptrdiff_t и int). На 32-битной системе их размеры совпадали, все замечательно работало. Когда в 64-битной программе изменился размер типа ptrdiff_t, то в стек стало попадать больше байт, чем затем извлекаться.

Пример 30. Ошибки в 32-битных модулях, работающих в WoW64

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

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

lRet = RegOpenKeyEx(HKEY_LOCAL_MACHINE,
  "SOFTWARE\\ODBC\\ODBC.INI\\ODBC Data Sources", 0,
  KEY_QUERY_VALUE,  &hKey);

Чтобы подружить эту программу с другими 64-битными частями, необходимо вписать ключ KEY_WOW64_64KEY:

lRet = RegOpenKeyEx(HKEY_LOCAL_MACHINE,
  "SOFTWARE\\ODBC\\ODBC.INI\\ODBC Data Sources", 0,
  KEY_QUERY_VALUE | KEY_WOW64_64KEY,  &hKey);

Заключение

Наилучший результат при поиске описанных в статье ошибок дает методика статического анализа кода. В качестве примера инструмента осуществляющего такой анализ, можно назвать разрабатываемый нами инструмент Viva64, входящий в состав PVS-Studio.

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

Библиографический список