Колекция от примери за 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 тук.

Проблемът е, че указателите на буфера и curr_pos може да са извън първите 4 Gbytes на адресното пространство в 64-битова система. В този случай изричното преобразуване на указателите към типа 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
//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-битова версия.

Неприятното при тази грешка е, че се появява нередовно или изобщо много рядко. Дали грешката ще се разкрие или не, зависи от областта на паметта, където е създаден обектът, посочен от указателя „този“. Ако обектът е създаден в 4-те най-малко значими Gbytes на адресното пространство, 64-битовата програма може да работи правилно. Грешката може да възникне неочаквано след дълго време, когато обектите ще започнат да се създават извън първите четири Gbytes поради разпределение на паметта.

В 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” в масива от низове и връща true, ако поне един низ съдържа последователността “ABC”. След повторно компилиране на 64-битовата версия на кода, тази функция винаги ще връща true.

Константата “string::npos” има стойност 0xFFFFFFFFFFFFFFFF от типа size_t в 64-битовата система. Когато поставите тази стойност в променливата „n“ от неподписан тип, тя се съкращава до 0xFFFFFFFF. В резултат на това условието “ n != string::npos” винаги е вярно, тъй като 0xFFFFFFFFFFFFFFFFFF не е равно на 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-битова грешка, свързана с тази функция. Нека първо разгледаме правилната версия на кода, където се извършва разпределението и се използват три масива, всеки от които е Gbyte:

#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 Gbytes. Тъй като компилаторът предполага, че резултатът от функцията има тип 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 наподобява конвенцията за бързо повикване в x86. В x64-конвенцията първите четири целочислени аргумента (отляво надясно) се предават в 64-битови регистри, използвани специално за тази цел:

RCX: 1-во цяло число

RDX: 2-ро цяло число

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

R9: 4-то цяло число

Всички останали цели аргументи се предават през стека. Указателят „този“ се счита за целочислен аргумент, така че винаги се поставя в регистъра 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 switch) в настройките на проекта и увеличете размера на резервирания стек три пъти. Този размер е 1 Mbyte по подразбиране.

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

Въпреки че се смята за лош стил в C++ да се използват функции с променлив брой аргументи като 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;
// 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

Ние сами не сме срещали тази грешка. Може би е рядко, но напълно възможно.

Двойният тип има размер 64 бита и е съвместим със стандарта IEEE-754 на 32-битови и 64-битови системи. Някои програмисти използват двойния тип, за да съхраняват и обработват цели числа:

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

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

Фигура 13 — Броят на значимите битове в типовете size_t и double

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

„Аритметика на адреса“ е средство за изчисляване на адрес на някакъв обект с помощта на аритметични операции над указатели, както и използване на указатели в операции за сравнение. Адресната аритметика се нарича още аритметика на указателя.

Много 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-type. В този случай се използва типът 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-types и да внимавате, когато работите с подписани и неподписани типове:

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 Gbytes памет на 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++, т.е. типът enum е 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 Gbytes от адресното пространство по време на тестването. В този случай 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++)
{ ... }

Този цикъл никога няма да спре, ако стойността на броя › 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); b,c) — Грешка при настройване на 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-битов код

Обърнете внимание, че знаковото разширение на типа unsigned short до 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 се преобразува от битовото поле на 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);

Не можете да използвате типове, които променят размера си в зависимост от средата за разработка в двоични интерфейси за обмен на данни. В 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-битово двойно, 8 неподписани 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-битовата система и всичко беше наред. Когато размерът на типа 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, който разработваме.

Методите за статично търсене на дефекти позволяват да се откриват дефекти въз основа на изходния програмен код. Поведението на програмата се оценява на всички пътеки за изпълнение едновременно. Поради това статичният анализ ви позволява да откриете дефекти, които възникват само при нестандартни пътеки за изпълнение с редки входни данни. Тази функция допълва други методи за тестване и повишава сигурността на приложенията. Системите за статичен анализ могат да се използват при одит на изходния код с цел систематично отстраняване на дефекти в съществуващи програми; те могат да се интегрират в процеса на разработка и автоматично да откриват дефекти в създавания код.

Препратки

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