Йо-хо-хо, и бутылка рому! Разбираем ошибки игрового движка Storm Engine

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

f18c6f603543c43f5c78963a444299b0.png

Storm Engine

Storm Engine — игровой движок, разрабатывавшийся с января 2000 года компанией Акелла для серии игр Корсары. 26 марта 2021 года исходный код движка открыт под лицензией GPLv3 и доступен на GitHub. Проект написан на C++.

Всего в проекте было обнаружено 235 предупреждений уровня High и 794 предупреждения уровня Medium. Многие из этих предупреждений указывают на ошибки, которые могут привести к неопределенному поведению программы. Другие предупреждения выявляют логические ошибки в коде — программа будет работать стабильно, но результаты этой работы будут отличаться от ожидаемых.

Разбор всех 1029 предупреждений — это материал, достаточный для написания целой книги, а не статьи. Кроме того, анализ почти всех этих предупреждений требует глубокого погружения в кодовую базу проекта, что долго и утомительно как описывать, так и читать. Поэтому в своей статье разберу только те ошибки и неточности, которые понятны без погружения в структуру проекта.

Найденные ошибки

Лишние проверки

Предупреждение PVS-Studio: V547 Expression 'nStringCode >= 0xffffff' is always false. dstring_codec.h 84

#define DHASH_SINGLESYM 255
....
uint32_t Convert(const char *pString, ....)
{
  uint32_t nStringCode;
  ....
  nStringCode = ((((unsigned char)pString[0]) << 8) & 0xffffff00) |
                  (DHASH_SINGLESYM)
  ....
  if (nStringCode >= 0xffffff)
  {
    __debugbreak();
  }
  return nStringCode;
}

Проведем оценку выражения, которое будет присвоено переменной nStringCode. Тип unsigned char принимает значения [0, 255], поэтому (unsigned char)pString[0] всегда меньше 2^8, после сдвига на *8 *получаем не более 2^16, конъюнкция не увеличивает это значение. После увеличиваем значение выражения не более чем на 255. Итого значение переменной nStringCode не превышает 2^16+256, что всегда меньше, чем 0xffffff = 2^24–1, и проверка оказалась всегда ложной. Таким образом, проверка на самом деле ни от чего не защищает, и её можно удалить из кода:

#define DHASH_SINGLESYM 255
....
uint32_t Convert(const char *pString, ....)
{
  uint32_t nStringCode;
  ....
  nStringCode = ((((unsigned char)pString[0]) << 8) & 0xffffff00) |
                (DHASH_SINGLESYM)
....
  return nStringCode;
}

Впрочем, спешить это делать не стоит. Данная проверка явно написана для защиты от ошибок, которые могут возникнуть в процессе модификации выражения или, например, константы DHASH_SINGLESYM. Это тот случай, когда формально анализатор прав, но это не значит, что он нашёл фрагмент кода, требующий обязательного исправления.

Предупреждение PVS-Studio: V560 A part of conditional expression is always true: 0×00 <= c. utf8.h 187

inline bool IsValidUtf8(....)
{
  int c, i, ix, n, j;
  for (i = 0, ix = str.length(); i < ix; i++)s
  {
    c = (unsigned char)str[i];
    if (0x00 <= c && c <= 0x7f)
      n = 0;
    ....
  }
  ....
}

Переменной c было присвоено значение беззнакового типа, поэтому проверка 0×00 <= c бессмысленна, и ее можно убрать. Сокращенный код:

inline bool IsValidUtf8(....)
{
  int c, i, ix, n, j;
  for (i = 0, ix = str.length(); i < ix; i++)s
  {
    c = (unsigned char)str[i];
    if (c <= 0x7f)
      n = 0;
    ....
  }
  ....
}

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

Предупреждение PVS-Studio: V557 Array overrun is possible. The value of 'TempLong2 — TempLong1 + 1' index could reach 520. internal_functions.cpp 1131

DATA *COMPILER::BC_CallIntFunction(....)
{
  if (TempLong2 - TempLong1 >= sizeof(Message_string))
  {
    SetError("internal: buffer too small");
    pV = SStack.Push();
    pV->Set("");
    pVResult = pV;
    return pV;
  }
  memcpy(Message_string, pChar + TempLong1, 
         TempLong2 - TempLong1 + 1);
  Message_string[TempLong2 - TempLong1 + 1] = 0;
  pV = SStack.Push();
}

В этом примере анализатор помогает найти off-by-one error.

Сначала проверяется, что значение TempLong2 — TempLong1 меньше размера строки Message_string, а затем выполняется присваивание по индексу TempLong2 — TempLong1 + 1. В случае, если TempLong2 — TempLong1 + 1 == sizeof (Message_string), проверка будет пройдена, внутренняя ошибка сгенерирована не будет, а при выполнении присваивания произойдет обращение к незарезервированной памяти, что приведет к неопределенному поведению программы. Необходимо поменять проверку:

DATA *COMPILER::BC_CallIntFunction(....)
{
  if (TempLong2 - TempLong1 + 1 >= sizeof(Message_string))
  {
    SetError("internal: buffer too small");
    pV = SStack.Push();
    pV->Set("");
    pVResult = pV;
    return pV;
  }
  memcpy(Message_string, pChar + TempLong1, 
         TempLong2 - TempLong1 + 1);
  Message_string[TempLong2 - TempLong1 + 1] = 0;
  pV = SStack.Push();
}

Присваивание переменной самой себе

Предупреждение PVS-Studio: V570 The 'Data_num' variable is assigned to itself. s_stack.cpp 36

uint32_t Data_num;
....
DATA *S_STACK::Push(....)
{
  if (Data_num > 1000)
  {
    Data_num = Data_num;
  }
  ....
}

Возможно, данный фрагмент кода был добавлен для отладки и впоследствии не был удален. Переменной *Data_num *вместо нового значения присваивается ее собственное значение. Сложно точно сказать, какое значение программист хотел присвоить. Вероятно, здесь должно быть присваивание значения другой переменной с похожим названием, но по невнимательности название было перепутано. Другой вариант — значение переменной хотели ограничить константой 1000, но опечатались. В любом случае это ошибка, и ее нужно исправить.

Разыменование нулевого указателя

Предупреждение PVS-Studio: V595 The 'rs' pointer was utilized before it was verified against nullptr. Check lines: 163, 164. Fader.cpp 163

uint64_t Fader::ProcessMessage(....)
{
  ....
  textureID = rs->TextureCreate(_name);
  if (rs)
  {
    rs->SetProgressImage(_name);
    ....
}

Здесь сначала происходит разыменование указателя rs, а потом этот указатель проверяется на равенство nullptr. Если указатель может быть равен nullptr, то разыменование нулевого указателя приведет к неопределенному поведению, поэтому проверку нужно делать до первого разыменования:

uint64_t Fader::ProcessMessage(....)
{
  ....
  if (rs)
  {
    textureID = rs->TextureCreate(_name);
    rs->SetProgressImage(_name);
    ....
}

Если же гарантируется, что всегда rs!= nullptr, то проверка if (rs) лишняя, и ее надо убрать:

uint64_t Fader::ProcessMessage(....)
{
  ....
  textureID = rs->TextureCreate(_name);
  rs->SetProgressImage(_name);
  ....
}

Есть и ещё один вариант. На самом деле, хотели проверить переменную textureID.

Всего в проекте встретилось 14 предупреждений V595.

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

Предупреждение PVS-Studio: V595 The 'pACh' pointer was utilized before it was verified against nullptr. Check lines: 1214, 1215. sail.cpp 1214

void SAIL::SetAllSails(int groupNum)
{
  ....
  SetSailTextures(groupNum, core.Event("GetSailTextureData", 
                 "l", pACh->GetAttributeAsDword("index",  -1)));
  if (pACh != nullptr){
  ....
}

При вычислении аргументов метода *Event *происходит разыменования указателя pACh, который в следующей строке кода проверяется на равенство nullptr. Если указатель может быть нулевым, то вызов функции SetSailTextures, порождающий разыменование pACh, должен выполняться после проверки:

void SAIL::SetAllSails(int groupNum)
{
  ....
  if (pACh != nullptr){
    SetSailTextures(groupNum, core.Event("GetSailTextureData", 
                    "l", pACh->GetAttributeAsDword("index",  -1)));
  ....
}

Если же гарантируется, что pACh — ненулевой, то проверку нужно убрать:

void SAIL::SetAllSails(int groupNum)
{
  ....
  SetSailTextures(groupNum, core.Event("GetSailTextureData", 
                  "l", pACh->GetAttributeAsDword("index",  -1)));
  ....
}

Ошибка new[] — delete

Предупреждение PVS-Studio: V611 The memory was allocated using 'new T[]' operator but was released using the 'delete' operator. Consider inspecting this code. It’s probably better to use 'delete [] pVSea;'. Check lines: 169, 191. SEA.cpp 169

struct CVECTOR
{
  public:
    union {
      struct
      {
        float x, y, z;
      };
      float v[3];
  };
};
....
struct SeaVertex
{
  CVECTOR vPos;
  CVECTOR vNormal;
  float tu, tv;
};
....
#define STORM_DELETE (x)
{ delete x; x = 0; }

void SEA::SFLB_CreateBuffers()
{
    ....
    pVSea = new SeaVertex[NUM_VERTEXS];
}
SEA::~SEA() {
....
STORM_DELETE(pVSea);
....
}

Использование макросов — практика, требующая особой внимательности. В данном случае использование макроса привело к ошибке: выделенная оператором new[] память освобождается неправильным оператором delete вместо правильного *delete[]*. Как результат, деструкторы элементов массива *pVSea *вызваны не будут. Иногда это может не проявляться — например, когда все деструкторы элементов массива и их полей тривиальны.

Однако если ошибка не проявляется на runtime — не значит, что ее нет. Дело в том, что реализация оператора new[] может быть устроена так, что в начало участка памяти, выделенного под массив, дополнительно помещается размер этого участка и количество элементов в массиве. Тогда при вызове несовместимого оператора delete информация о размере блока и числе элементов, вероятно, будет интерпретирована неправильно, и результат такой операции не определен. Также не исключена ситуация, когда память для отдельных элементов и массивов выделяется из разных пулов памяти. Тогда попытка вернуть выделенную для массива память в пул, предназначенный для скаляров, приведет к катастрофе.

Эта ошибка особенна опасна тем, что она долгое время может не проявлять себя и выстрелить в неожиданный момент. Всего было выявлено 15 ошибок такого типа, вот некоторые из них:

  • V611 The memory was allocated using 'new T[]' operator but was released using the 'delete' operator. Consider inspecting this code. It’s probably better to use 'delete [] m_pShowPlaces;'. Check lines: 421, 196. ActivePerkShower.cpp 421

  • V611 The memory was allocated using 'new T[]' operator but was released using the 'delete' operator. Consider inspecting this code. It’s probably better to use 'delete [] pTable;'. Check lines: 371, 372. AIFlowGraph.h 371

  • V611 The memory was allocated using 'new T[]' operator but was released using the 'delete' operator. Consider inspecting this code. It’s probably better to use 'delete [] vrt;'. Check lines: 33, 27. OctTree.cpp 33

  • V611 The memory was allocated using 'new T[]' operator but was released using the 'delete' operator. Consider inspecting this code. It’s probably better to use 'delete [] flist;'. Flag.cpp 738

  • V611 The memory was allocated using 'new T[]' operator but was released using the 'delete' operator. Consider inspecting this code. It’s probably better to use 'delete [] rlist;'. Rope.cpp 660

Анализ кода показал, что макрос STORM_DELETE используется во многих из найденных случаев. Однако простое изменение оператора delete на оператор delete[] приведет к новым ошибкам, потому что этот макрос используется также и для освобождения памяти, выделенной оператором new. Поэтому исправить код нужно, добавив новый макрос STORM_DELETE_ARRAY, который использует правильный оператор delete[]:

struct CVECTOR
....
struct SeaVertex
{
  CVECTOR vPos;
  CVECTOR vNormal;
  float tu, tv;
};
....
#define STORM_DELETE (x)
{ delete x; x = 0; }

#define STORM_DELETE_ARRAY (x)
{ delete[] x; x = 0; }

void SEA::SFLB_CreateBuffers()
{
    ....
    pVSea = new SeaVertex[NUM_VERTEXS];
}
SEA::~SEA() 
{
  ....
  STORM_DELETE_ARRAY(pVSea);
  ....
}

Присваивание дважды подряд

Предупреждение PVS-Studio: V519 The 'h' variable is assigned values twice successively. Perhaps this is a mistake. Check lines: 385, 389. Sharks.cpp 389

inline void Sharks::Shark::IslandCollision(....)
{
  if (h < 1.0f)
  {
    h -= 100.0f / 150.0f;
    if (h > 0.0f)
    {
      h *= 150.0f / 50.0f;
    }
    else
      h = 0.0f;
    h = 0.0f;
    vx -= x * (1.0f - h);
    vz -= z * (1.0f - h);
}

В этом фрагменте кода в случае h < 1.0f сначала производятся вычисления над переменной h, а затем ей присваивается значение 0. Как результат — переменная h всегда равна 0, что является ошибкой, и последнее присваивание переменной h нужно убрать:

inline void Sharks::Shark::IslandCollision(....)
{
  if (h < 1.0f)
  {
    h -= 100.0f / 150.0f;
    if (h > 0.0f)
    {
      h *= 150.0f / 50.0f;
    }
    else
      h = 0.0f;
    vx -= x * (1.0f - h);
    vz -= z * (1.0f - h);
}

Разыменование указателя, полученного из функции realloc или malloc

Предупреждение PVS-Studio: V522 There might be dereferencing of a potential null pointer 'pTable'. Check lines: 36, 35. s_postevents.h 36

void Add(....)
{
  ....
  pTable = (S_EVENTMSG **)realloc(
                         pTable, nClassesNum * sizeof(S_EVENTMSG *));
  pTable[n] = pClass;
  ....
};

Функция realloc в случае, когда недостаточно памяти для расширения блока до заданного размера, возвращает NULL. Если произойдет такая ситуация, то при вычислении выражения *pTable[n] *произойдет разыменование нулевого указателя, что приведет к неопределенному поведению программы. Кроме того, из-за того, что указатель pTable будет перезаписан, адрес исходного блока памяти может быть утерян. Необходимо добавить проверку и использовать дополнительный указатель:

void Add(....)
{
  ....
  S_EVENTMSG ** newpTable 
    = (S_EVENTMSG **)realloc(pTable, 
                             nClassesNum * sizeof(S_EVENTMSG *));
  if(newpTable) 
  {
    pTable = newpTable;
    pTable[n] = pClass;
    ....
  }
  else
  {
  // Обрабатываем ситуацию, когда realloc не смог сделать реаллокацию
  }
};

Встречаются подобные ошибки и в использовании функции malloc:

Предупреждение PVS-Studio: V522 There might be dereferencing of a potential null pointer 'label'. Check lines: 116, 113. geom_static.cpp 116

GEOM::GEOM(....) : srv(_srv)
{
  ....
  label = static_cast

Здесь нужно добавить проверку:

GEOM::GEOM(....) : srv(_srv)
{
  ....
  label = static_cast

Всего анализатор выявил 18 ошибок подобного типа.

О том, к чему могут привести такие ошибки и почему так важно их не допускать, вы можете прочитать в этой статье.

Остаток по модулю 1

Предупреждение PVS-Studio: V1063 The modulo by 1 operation is meaningless. The result will always be zero. WdmSea.cpp 205

void WdmSea::Update(float dltTime)
{
  long whiteHorses[1];
  ....
  wh[i].textureIndex = rand() % (sizeof(whiteHorses) / sizeof(long));
}

В данном случае вычисляется размер массива *whiteHorses, *равный 1, и по нему берется модуль, что бессмысленно — результатом всегда будет 0. Возможно, ошибка в объявлении переменной *whiteHorses *– размер массива должен быть изменен. Не исключаю, что в этом коде и вовсе нет ошибки, и бессмысленная на данный момент конструкция *rand () % (sizeof (whiteHorses) / sizeof (long)) *сохранена осознанно с оглядкой на будущие изменения. Если ожидается, что размер массива whiteHorses в будущем будет изменен и надо будет генерировать случайный индекс в нем, то этот код имеет право на жизнь и в правках не нуждается. Независимо от того, прав ли программист в этом фрагменте кода или ошибся, на него стоит обратить внимание и перепроверить, о чем справедливо сообщает анализатор.

std: vector vs std: deque

Кроме явных ошибок и неточностей в коде, анализатор PVS-Studio помогает оптимизировать написанный код.

Предупреждение PVS-Studio: V826 Consider replacing the 'aLightsSort' std: vector with std: deque. Overall efficiency of operations will increase. Lights.cpp 471

void Lights::SetCharacterLights(....)
{
  std::vector aLightsSort;
  for (i = 0; i < numLights; i++)
    aLightsSort.push_back(i);
  for (i = 0; i < aMovingLight.size(); i++)
  {
    const auto it = std::find(aLightsSort.begin(),aLightsSort.end(), 
                              aMovingLight[i].light);
    aLightsSort.insert(aLightsSort.begin(), aMovingLight[i].light);
  }
}

Здесь создается *std: vector aLightsSort, *в начало которого потом вставляются элементы.

Почему плохо вставлять много раз в начало std: vector? Потому что каждая, абсолютно каждая такая вставка приводит к релокации (reallocation) буфера вектора. Будет выделен новый буфер, в начало него будет записано вставляемое значение и скопированы значения из старого буфера. А почему бы нам просто не записать новое значение перед нулевым элементом старого буфера? А потому что std: vector так не умеет.

Зато так умеет std: deque. Буфер этого контейнера реализован в виде кольцевого буфера, что позволяет вставлять и удалять элементы и в начало, и в конец без копирования. Вставка в начало std: deque выглядит ровно так, как мы и хотим — просто вставить новое значение перед нулевым элементом.

Поэтому в данном фрагменте кода стоит заменить *std: vector *на std: deque:

void Lights::SetCharacterLights(....)
{
  std::deque aLightsSort;
  for (i = 0; i < numLights; i++)
    aLightsSort.push_back(i);
  for (i = 0; i < aMovingLight.size(); i++)
  {
    const auto it = std::find(aLightsSort.begin(),aLightsSort.end(), 
                              aMovingLight[i].light);
    aLightsSort.push_front(aMovingLight[i].light);
  }
}

Заключение

PVS-Studio выявил множество ошибок и недочетов в исходном коде Storm Engine. Многие предупреждения указывали на код, помеченный самими разработчиками как требующий доработки — возможно, эти ошибки были найдены как раз инструментами статического анализа, а может быть, их обнаружили на обзоре кода (code review). Другие предупреждения указывали на непомеченный комментариями код (а значит и не были найдены программистами) — в частности все, разобранные в статье, — и указывали на ошибки, требующие исправлений. Если вас заинтересовал проект Storm Engine и найденные в нем ошибки, вы можете повторить проведенный мною анализ самостоятельно. Также предлагаю познакомиться с подборкой статей про проверку исходных проектов — в ней вы найдете результаты анализа других проектов.

Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Yo, Ho, Ho, And a Bottle of Rum — Or How We Analyzed Storm Engine’s Bugs.

© Habrahabr.ru