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

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

Введение

Наша компания ООО Системы программной верификации разрабатывает специальный статический анализатор Viva64, который выявляет 64-битные ошибки в коде приложений C / C ++. В процессе разработки мы постоянно пополняем нашу коллекцию примеров 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 символов, поскольку переменная длины имеет тип int. Но нас интересует другая ошибка, поэтому предположим, что программа работает с небольшим буфером и здесь правильно использовать тип int.

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

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

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

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

Пример 3. Неверный # ifdef’s

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

#ifdef _WIN32 // Win32 code
  cout << "This is Win32" << endl;
#else         // Win16 code
  cout << "This is Win16" << endl;
#endif
//Alternative incorrect variant:
#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, "Unknown platform ");
#endif

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

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

В устаревших программах, особенно написанных на C, часто можно встретить фрагменты кода, в которых указатель хранится в типе 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);

Вы не можете ни в чем упрекнуть программиста, когда-то написавшего этот код. В процессе разработки он создал этот код, опираясь на свой опыт и 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 в тип без знака и подобные преобразования легко диагностируются по предупреждениям компилятора. Но в больших программах такие предупреждения можно легко пропустить. Рассмотрим пример, похожий на реальный код, где предупреждение игнорировалось, потому что программисту казалось, что при работе с короткими строками ничего плохого не произойдет.

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» в массиве строк и возвращает истину, если хотя бы одна строка содержит последовательность «ABC». После перекомпиляции 64-битной версии кода эта функция всегда будет возвращать true.

Константа «string :: npos» имеет значение 0xFFFFFFFFFFFFFFFF типа size_t в 64-битной системе. При помещении этого значения в переменную «n» беззнакового типа оно усекается до 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. Неопределенные функции в C

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

В C вы можете использовать функции без предварительного объявления. Давайте посмотрим на интересный пример 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» не включен, компилятор C предполагает, что функция malloc вернет тип int. Первые два экземпляра выделения памяти, скорее всего, будут успешными. Когда память выделяется в третий раз, функция malloc вернет адрес массива за пределами первых 2 Гбайт. Поскольку компилятор предполагает, что результат функции имеет тип int, он неправильно интерпретирует результат и сохранит неправильное значение указателя в массиве Pointers.

Давайте рассмотрим ассемблерный код, сгенерированный компилятором Visual C ++ для 64-битной отладочной версии. В начале есть правильный код, который будет сгенерирован при наличии определения функции 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 (преобразовать двойное слово в четверное слово). Компилятор предполагает, что результат содержится в регистре eax, и расширяет его до 64-битного значения, чтобы записать его в массив Pointers. Соответственно, будут потеряны старшие биты регистра rax. Даже если адрес выделенной памяти лежит в пределах первых четырех гигабайт, мы все равно получим неверный результат, если старший бит регистра eax равен 1. Например, адрес 0x81000000 превратится в 0xFFFFFFFF81000000.

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

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

Рисунок 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 элементов, мы увидим странное сообщение о том, что программа не может выделить память (только) для 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);
};

Типы DWORD_PTR и DWORD совпадают в 32-битном режиме, но не совпадают в 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);

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

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

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

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

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

size_t count = 500;
size_t *values = new size_t[count];
// Only a part of the buffer will be filled
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-битная. Но увеличение потребления памяти стека может вызвать переполнение стека.

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

При разработке соглашений о вызовах в системах 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: резервный переключатель) в настройках проекта и увеличьте размер резервируемого стека в три раза. По умолчанию этот размер составляет 1 Мбайт.

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

Хотя использование функций с переменным числом аргументов, таких как printf и scanf, в C ++ считается плохим стилем, они по-прежнему широко используются. Эти функции вызывают массу проблем при переносе приложений на другие системы, в том числе на 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;
// A wrong value will be printed
printf(invalidFormat, value);

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

// The pointers were saved as strings here
int *p1, *p2;
....
char str[128];
sprintf(str, "%X %X", p1, p2);
// In another function this string
// was processed in the following way:
void foo(char *str)
{
  int *p1, *p2;
  sscanf(str, "%X %X", &p1, &p2);
  // The result is incorrect values of p1 and p2 pointers.
  ...
}

Формат «% 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-битные целые значения без потерь. Но когда вы пытаетесь сохранить 64-битное целое число в double, вы можете потерять точное значение (см. Рисунок 13).

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

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

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

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

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

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

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

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

Причина, по которой A + B! = A - (-B) в программе Win64, поясняется на рисунке 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 и его тип беззнаковый. Этот код эффективен в 32-битной версии, поскольку добавление указателя к 0xFFFFFFFB эквивалентно его уменьшению на 5. В 64-битной версии указатель будет указывать далеко за границы массива p1 после добавления к 0xFFFFFFFB (см. Рис. 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];
}

Этот код взят из реального приложения математического моделирования, где размер физической памяти является очень важным ресурсом, поэтому возможность использовать более 4 Гбайт памяти в 64-битной архитектуре значительно увеличивает вычислительную мощность. В программах этого класса для экономии памяти часто используются одномерные массивы, и они обрабатываются как трехмерные массивы. Для этого существуют функции, аналогичные 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 согласно правилам C ++, и поэтому стараются избежать переполнения. Но переполнение может произойти внутри подвыражений 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 находится в первом элементе массива sizePtr (см. Рисунок 17). Иногда такое поведение является преднамеренным, но чаще всего это ошибка.

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

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

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

Рисунок 20. Механизм бесконечного цикла

Пример 23. Битовые операции и операция НЕ.

Битовые операции требуют от программиста особой осторожности при разработке кроссплатформенных приложений, в которых типы данных могут иметь разные размеры. Поскольку миграция программы на 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 - а) правильная установка 31-го бита в 32-битном коде (отсчет битов начинается с 0); б, в) - ошибка установки 32-го бита в 64-битной системе (два варианта поведения, которые зависят от компилятора)

Чтобы исправить код, вы должны сделать тип константы «1» таким же, как тип переменной маски:

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-битном коде

Обратите внимание, что расширение знака беззнакового короткого типа до int происходит во время вычисления выражения «obj.a ‹---------------- 17». Следующий код поясняет:

#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 преобразуется из битового поля беззнакового короткого типа в int. Выражение «obj.a ‹

Будьте осторожны при работе с битовыми полями. Чтобы избежать описанной в нашем примере ситуации, вам просто нужно преобразовать 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);

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

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

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

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

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

При разработке интерфейса или формата двоичных данных вы должны помнить о порядке байтов. Если в 64-битной системе, на которую вы переносите свое 32-битное приложение, другой порядок байтов, вам просто нужно принять это во внимание для своего кода. Для преобразования между прямым порядком байтов и прямым порядком байтов вы можете использовать функции htonl (), htons (), bswap_64, etc.

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

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

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

Рассмотрим описание проблемы, найденной на каком-то форуме:

«Сегодня я столкнулся с проблемой в Linux. Есть структура данных, состоящая из нескольких полей: 64-битного типа double, 8 беззнаковых символов и одного 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-битной системе и все было в порядке. Когда размер типа ptrdiff_t изменялся в 64-битной программе, количество байтов, помещаемых в стек, становилось больше, чем количество байтов, которые были бы тогда из него извлечены.

Пример 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.

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

использованная литература

Статья опубликована с разрешения автора