>
>
>
Проверка игрового движка qdEngine, част…

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

Проверка игрового движка qdEngine, часть третья: дополнительная десятка багов

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

Предыдущие статьи о проверке игрового движка qdEngine:

Без долгих предисловий приступим к рассмотрению ассорти из багов :)

Предупреждение N1: ошибка в обработчике ошибок

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

В результате обработчики ошибок сами часто содержат ошибки. Как-нибудь напишу про это отдельную статью.

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

class CAVIGenerator  
{
  ....
  _bstr_t m_sFile;
  ....
};

HRESULT CAVIGenerator::InitEngine()
{
  ....
  TCHAR szBuffer[1024];
  ....
  if (hr != AVIERR_OK)
  {
    _tprintf(szBuffer,
             _T("AVI Engine failed to initialize. Check filename %s."),m_sFile);
    m_sError=szBuffer;
  ....
};

Предупреждение PVS-Studio: V510 The 'printf' function is not expected to receive class-type variable as third actual argument. AVIGenerator.cpp 132

Дело в том, что m_sFile – это класс типа _bstr_t. Попытка распечатать содержимое класса как обыкновенную строчку приведёт к неопределённому поведению. На практике, скорее всего, вместо имени файла будут распечатаны мусорные символы. Если мусорных символов будет слишком много, то переполнится буфер szBuffer[1024]. Переполнение буфера, в свою очередь, можно рассматривать как потенциальную уязвимость.

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

Код можно исправить, используя перегруженные операторы класса _bstr_t:

// Extract the pointers to the encapsulated Unicode or multibyte BSTR object.
operator wchar_t*
operator char*

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

_tprintf(szBuffer,
         _T("AVI Engine failed to initialize. Check filename %s."),
         (LPCSTR)m_sFile);

Тип LPCSTR развернётся в зависимости от режима компиляции (UNICODE приложение или нет) в const wchar_t* или const char*.

Предупреждение N2: недостижимый код

bool qdGameScene::follow_path_seek(qdGameObjectMoving* pObj,
                                   bool lock_target)
{
  if (qdGameObjectMoving::FOLLOW_UPDATE_PATH == pObj->follow_condition())
    selected_object_->set_grid_zone_attributes(sGridCell::CELL_SELECTED);

  return pObj->move(selected_object_->last_move_order(), lock_target);

  if (qdGameObjectMoving::FOLLOW_UPDATE_PATH == pObj->follow_condition())
    selected_object_->drop_grid_zone_attributes(sGridCell::CELL_SELECTED);
}

Предупреждение PVS-Studio: V779 Unreachable code detected. It is possible that an error is present. qd_game_scene.cpp 1245

Код после оператора return не выполняется.

Блок недостижимого кода точно такой же, как и перед оператором return. Скорее всего, повторяющийся код получился случайно в процессе неаккуратного редактирования кода или слияния веток. Думаю, нижнюю часть кода можно просто удалить.

Предупреждение N3: new [] / delete

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

bool grFont::load_index(XStream& fh)
{
  int buf_sz = fh.size();
  char* buf = new char[buf_sz];
  ....
  delete buf;
  return true;
}

Предупреждение PVS-Studio: V611 The memory was allocated using the 'operator new[]' but was released using the 'operator delete'. The 'delete[] buf;' statement should be used instead. gr_font.cpp 72

Выделяется массив, поэтому для освобождения должен использоваться оператор delete[]. Правильный код:

bool grFont::load_index(XStream& fh)
{
  int buf_sz = fh.size();
  char* buf = new char[buf_sz];
  ....
  delete[] buf;
  return true;
}

Хотя нет. Это тоже неправильный код. Правильно — это использовать умные указатели:

bool grFont::load_index(XStream& fh)
{
  int buf_sz = fh.size();
  auto buf = std::make_unique<char[]>(buf_sz);
  ....
  return true;
}

P.S.: Ещё несколько мест с точно такой же ошибкой:

  • V611 The memory was allocated using the 'operator new[]' but was released using the 'operator delete'. The 'delete[] buf;' statement should be used instead. gr_font.cpp 101
  • V611 The memory was allocated using the 'operator new[]' but was released using the 'operator delete'. The 'delete[] alpha_buffer_;' statement should be used instead. Check lines: 25, 168. gr_font.cpp 25
  • V611 The memory was allocated using the 'operator new[]' but was released using the 'operator delete'. The 'delete[] buffer;' statement should be used instead. qda_editor.cpp 1012

Предупреждение N4: функция не возвращает значение

class qdSound : public qdNamedObject, public qdResource
{
  ....
  //! Возвращает длительность звука в секундах.
  float length() const { 
#ifndef __QD_SYSLIB__
    return sound_.length(); 
#endif
  }
  ....
}

Предупреждение PVS-Studio: V591 Non-void function should return a value. qd_sound.h 70

Если определён макрос __QD_SYSLIB__, то функция length будет устроена так:

float length() const { 
}

Вызов такой функции приводит к неопределённому поведению.

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

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

#ifndef __QD_SYSLIB__
  float length() const { 
    return sound_.length(); 
  }
#endif

Это приведёт к тому, что часть внешнего кода перестанет компилироваться. Вот и отлично. Этот код тоже надо переписать, так как он не имеет смысла, раз вызывает "мёртвую" функцию length.

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

#ifndef __QD_SYSLIB__
  float length() const { 
    return sound_.length(); 
  }
#else
  [[noreturn]] float length() const { 
    throw std::logic_error { "Function not implemented" };
  }
#endif

Предупреждение N5: утечка памяти

template<class FilterClass>
LineContribType* C2PassScale<FilterClass>::AllocContributions(UINT uLineLength, 
                                                              UINT uWindowSize)
{
  static LineContribType line_ct;

  LineContribType *res = new LineContribType; 

  line_ct.WindowSize = uWindowSize; 
  line_ct.LineLength = uLineLength; 

  if (contribution_buffer_.size() < uLineLength)
    contribution_buffer_.resize(uLineLength);

  line_ct.ContribRow = &*contribution_buffer_.begin();

  if (weights_buffer_.size() < uLineLength * uWindowSize)
    weights_buffer_.resize(uLineLength * uWindowSize);

  double* p = &*weights_buffer_.begin();

  for (UINT u = 0; u < uLineLength; u++) {
    line_ct.ContribRow[u].Weights = p;
    p += uWindowSize;
  }
  return &line_ct;
}

Предупреждение PVS-Studio: V773 The function was exited without releasing the 'res' pointer. A memory leak is possible. 2PassScale.h 107

Вот эта строчка в коде лишняя:

LineContribType *res = new LineContribType;

Созданный объект нигде не используется и не удаляется. Единственное, что происходит — утечка памяти.

Предупреждение N6: ошибка в логике

bool qdConditionGroup::remove_condition(int condition_id)
{
  ....
  conditions_container_t::iterator it1 = std::find(conditions_.begin(),
                                                   conditions_.end(),
                                                   condition_id);
  if (it1 != conditions_.end())
    return false;

  conditions_.erase(it1);
  return true;
}

Предупреждение PVS-Studio: V783 Dereferencing of the invalid iterator 'it1' might take place. qd_condition_group.cpp 58

Происходит попытка удаления несуществующего элемента:

conditions_.erase(conditions_.end());

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

if (it1 == conditions_.end())
  return false;

conditions_.erase(it1);

Предупреждение N7: опасная работа с типом HRESULT

class winVideo
{
  ....
  struct IGraphBuilder*  graph_builder_;
  ....
};

bool winVideo::open_file(const char *fname)
{
  ....
  if (graph_builder_ -> RenderFile(wPath,NULL))
  {
    close_file(); return false;
  }
  ....
}

На первый взгляд кажется, что с кодом всё хорошо. Но дело в том, что функция RenderFile возвращает тип HRESULT:

HRESULT RenderFile(
  [in] LPCWSTR lpcwstrFile,
  [in] LPCWSTR lpcwstrPlayList
);

Поэтому проверка является ненадёжной.

Предупреждение PVS-Studio: V545 Such conditional expression of 'if' statement is incorrect for the HRESULT type value 'graph_builder_->RenderFile(wPath, 0)'. The SUCCEEDED or FAILED macro should be used instead. WinVideo.cpp 139

HRESULT — это 32-разрядное значение, разделённое на три различных поля: код серьёзности ошибки, код устройства и код ошибки. Для проверки значений типа HRESULT предназначены такие макросы, как SUCCEEDED, FAILED.

Тип HRESULT имеет множество состояний. Это может быть 0L (S_OK), 0x80000002L (Ran out of memory), 0x80004005L (unspecified failure) и т. д. Обратите внимание, что состояние S_OK кодируется как 0.

Соответственно, любой статус, кроме S_OK, считается ошибочным, и функция досрочно завершает работу. В целом, видимо, этот код сейчас работает правильно, но написан небезопасно. Небольшой рефакторинг в случае, если кто-то подумает, что функция RenderFile возвращает тип bool, приведёт к багу.

Правильная проверка:

if (FAILED(graph_builder_ -> RenderFile(wPath,NULL)))
{
  close_file(); return false;
}

Предупреждение N8: false вместо nullptr

Этот фрагмент кода, как и рассмотренный выше, работает. Но назвать правильным его нельзя.

const char* qdInterfaceDispatcher::get_save_title() const
{
  if (!cur_screen_)
    return false;
  ....
  return false;
}

Предупреждения PVS-Studio на строчки "return false":

  • V601 The 'false' value is implicitly cast to the pointer. qd_interface_dispatcher.cpp 772
  • V601 The 'false' value is implicitly cast to the pointer. qd_interface_dispatcher.cpp 783

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

const char* qdInterfaceDispatcher::get_save_title() const
{
  if (!cur_screen_)
    return nullptr;
  ....
  return nullptr;
}

Предупреждение N9: что-то не так, но не знаю что

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

Vect2s
qdGameObjectMoving::get_nearest_walkable_point(const Vect2s& target) const
{
  ....
  // Идем с конца. Если натыкаемся на проходимую точку, отличную от начальной
  bool fir_step = true;
  if (abs(x2 - x1) > abs(y2 - y1)) {
    int dx = int(float(x2 - x1)/dr.x);
    do {
      if (false == is_walkable(Vect2s(r.xi(),r.yi())))
      {
        // Если только первый шаг, то неудача
        if (fir_step) return Vect2s(-1, -1);
        r -= dr;
        return Vect2s(r.xi(),r.yi());
      }

      fir_step = false;
      r += dr;
    } while (--dx >= 0);
  }
  else {
    int dy = int(float(y2 - y1)/dr.y);
    do {
      if (false == is_walkable(Vect2s(r.xi(),r.yi())))
      {
        if (fir_step) return Vect2s(-1, -1);
        r -= dr;
        return Vect2s(r.xi(),r.yi());
      }
      
      fir_step = false;
      r += dr;
    } while (--dy >= 0);
  }

  // Если шаг так и не был сделан
  if (fir_step) return trg;
  ....
}

Прошу изучить циклы. Если они не заканчиваются выходом из функции, то переменная fir_step по завершению циклов всегда равна false.

Соответственно, этот код не имеет смысла:

// Если шаг так и не был сделан
if (fir_step) return trg;

Об этом и предупреждает анализатор PVS-Studio: V547 Expression 'fir_step' is always false. qd_game_object_moving.cpp 1724

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

Предупреждение N10: strictly aligned

bool zipContainer::find_index()
{
  const int buf_sz = 1024;
  char buf[buf_sz];

  stream_.seek(0,XS_END);

  while (stream_.tell() >= buf_sz) {
    stream_.seek(-buf_sz,XS_CUR);
    stream_.read(buf,buf_sz);

    const unsigned long idx_signature = 0x06054b50L;

    for (int i = 0; i < buf_sz - sizeof(long) * 3; i++) {
      if (*((unsigned long*)(buf + i)) == idx_signature) {
        XBuffer xbuf(buf + i + sizeof(long) + sizeof(unsigned short) * 4,
                     sizeof(long) * 2);
        xbuf > index_size_ > index_offset_;
        return true;
      }
    }
  }

  return false;
}

Предупреждение PVS-Studio: V1032 The pointer 'buf' is cast to a more strictly aligned pointer type. zip_container.cpp 237

В байтовом буфере происходит поиск сигнатуры 0x06054b50L. Это сделано непереносимым, опасным образом.

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

  • Сигнатура из 4 байт 0x06054b50;
  • Или сигнатура из 8 байт 0x0000000006054b50.

Вряд ли предполагается, что сигнатура может быть разного размера. Скорее всего, она всегда 4-байтовая. Поэтому правильнее использовать тип фиксированного размера uint32_t.

Однако предупреждение анализатора про иное. Дело в том, что для поиска сигнатуры используется указатель типа char*. Чтобы произвести проверку, на каждой итерации адрес указателя (char *) интерпретируется как блок из 4/8 байт (unsigned long *). Картинка для пояснения:

Проблема в том, что не учитывается выравнивание. Значения типа unsigned long должны лежать по адресам, кратным 4 или 8 байтам (или другому значению, в зависимости от архитектуры).

Доступ по невыровненному адресу приводит к неопределённому поведению. Как оно себя проявит, предсказать невозможно, и зависит это от архитектуры микропроцессора и компилятора. Наиболее вероятные сценарии:

  • Произойдёт аварийное завершение программы.
  • Будет прочитано неверное значение. Например, могут не учитываться два младших бита указателя и чтение всё равно будет выполнятся по границам, кратным 4 байтам.
  • Чтение данных будет выполняться медленно, так как процессору придётся выполнить дополнительные операции.
  • Всё работает корректно, по крайней мере пока :)

Исправить проблему можно разными вариантами. Самый простой вариант — использовать функцию memcmp, которая работает на уровне массива байт.

if (memcmp(buf + i, &idx_signature, sizeof(idx_signature)) == 0)

А можно ли написать код на современном C++, не используя функцию memcmp из мира C?

Можно, но, если честно, получается немного длиннее. Поэтому приведу такой вариант скорее из спортивного интереса:

const uint32_t idx_sig = 0x06054b50L;

const std::ranges::subrange
  haystack { buf, buf + buf_sz - sizeof(idx_sig) * 3 };
const auto needle = std::bit_cast<std::array<char, sizeof(idx_sig)>>(idx_sig);

if (auto res = std::ranges::search(haystack, needle); !std::empty(res))
{
  XBuffer xbuf(it + sizeof(uint32_t) + sizeof(uint16_t) * 4,
               sizeof(uint32_t) * 2);
  xbuf > index_size_ > index_offset_;
  return true;
}

Ещё что-то осталось?

Есть ещё одна ошибка, заслуживающая рассмотрения. Она связанна с объектно-ориентированным программированием и потребует подробного рассмотрения, поэтому я вынесу её в отдельную, последнюю статью.

Спасибо за внимание. Подписывайтесь на ежемесячный дайджест статей, чтобы не пропустить что-то интересное.