Мы в PVS-Studio часто проверяем открытые проекты и пишем статьи об этом. Иногда при написании статьи случаются интересные ситуации или попадаются особенно эпичные ошибки, тогда хочется написать про это отдельную небольшую заметку. Сейчас совпали оба случая.

Вступление

В данный момент я пишу статью про проверку проекта DuckStation. Это эмулятор консоли Sony PlayStation. Проект довольно интересный и активно развивающийся. Я нашёл там несколько занимательных багов, одним из которых хочу поделиться прямо сейчас. Он продемонстрирует:

  • как всё же невнимателен может быть человек, даже если является опытным специалистом.

  • как статический анализ подстраховывает человека от его рассеянности и невнимательности.

Показательный пример ошибки

Предупреждение анализатора PVS-Studio: V726 An attempt to free memory containing the 'wbuf' array by using the 'free' function. This is incorrect as 'wbuf' was created on stack. log.cpp 216

template <typename T>
static ALWAYS_INLINE void FormatLogMessageAndPrintW(....)
{
  ....
  wchar_t wbuf[512];
  wchar_t* wmessage_buf = wbuf;
  ....
  if (wmessage_buf != wbuf)
  {
    std::free(wbuf);        // <=
  }
  if (message_buf != buf)
  {
    std::free(message_buf);
  }
  ....
}

В изначальной версии пишущейся статьи, я описал этот баг так:

Здесь анализатор обнаружил ошибочный код, в котором осуществляется попытка удаления массива, выделенного на стеке через функцию std::free. Так как память не была выделена на куче, не стоит также и вызывать каких-либо специальных функций для её очистки – она будет произведена автоматически при уничтожении объекта.

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

У нас в компании после написания статьи программистом её вычитывает опытный коллега и даёт рекомендации по её доработке. Этот случай не исключение, и вот какой комментарий дал ревьювер в процессе вычитки статьи на моё описание ошибки:

Здесь нет ошибки. Это ложное срабатывание, анализатор не осилил. Там в середине под условием есть динамическое выделение памяти под сообщение функцией malloc. Проверка if (wmessage_buf != wbuf) служит для того, чтобы определить, нужно вызывать std::free или нет.

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

template <typename T>
static ALWAYS_INLINE void FormatLogMessageAndPrintW(
                                             const char* channelName, 
                                             const char* functionName, 
                                             LOGLEVEL level,
                                             const char* message, 
                                             bool timestamp, 
                                             bool ansi_color_code,
                                             bool newline, 
                                             const T& callback)
{
  char buf[512];
  char* message_buf = buf;
  int message_len;
  if ((message_len = FormatLogMessageForDisplay(message_buf,
                       sizeof(buf), channelName, functionName,
                       level, 
                       message, timestamp,
                       ansi_color_code, newline)) > (sizeof(buf) - 1))
  {
    message_buf = static_cast<char*>(std::malloc(message_len + 1));
    message_len = FormatLogMessageForDisplay(message_buf, 
                 message_len + 1, channelName, functionName, 
                 level, message, timestamp, ansi_color_code, newline);
  }
  if (message_len <= 0)
    return;

  // Convert to UTF-16 first so unicode characters display correctly.
  // NT is going to do it anyway...
  wchar_t wbuf[512];
  wchar_t* wmessage_buf = wbuf;
  int wmessage_buflen = countof(wbuf) - 1;
  if (message_len >= countof(wbuf))
  {
    wmessage_buflen = message_len;
    wmessage_buf = static_cast<wchar_t*>
               (std::malloc((wmessage_buflen + 1) * sizeof(wchar_t)));
  }

  wmessage_buflen = MultiByteToWideChar(CP_UTF8, 0, message_buf,
                    message_len, wmessage_buf, wmessage_buflen);

  if (wmessage_buflen <= 0)
    return;

  wmessage_buf[wmessage_buflen] = '\0';
  callback(wmessage_buf, wmessage_buflen);

  if (wmessage_buf != wbuf)
  {
    std::free(wbuf);                        // <=
  }

  if (message_buf != buf)
  {
    std::free(message_buf);
  }
}

Действительно, если длина сообщения больше или равна countof(wbuf), под него создастся новый буфер на куче.

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

Категорически не cогласен, давай смотреть на код: буфер на стеке, динамическая аллокация нового буфера на куче, освобождение не того буфера. Если в локальный буфер на стеке не помещается строчка, то мы кладём её в динамически выделенный буфер по указателю wmessage_buf. И, как видно по коду, ниже есть 2 ветки с освобождением памяти, если динамическая аллокация произошла (это делают проверками вроде wmessage_buf != wbuf). Только в первой ветке освобождается не та память, на что мы и ругаемся. А уже во второй освобождается правильный буфер, и на этот код мы как раз не ругаемся.

Действительно, ошибка всё же есть, и программисту, написавшему этот код, следовало очистить буфер wmessage_buf, по аналогии с тем, как он сделал это ниже.

Ответ коллеги был краток:

Согласен. Поспешил.

P.S. С меня пиво.

Заключение

Любой статический анализатор, к сожалению, выдаёт ложно-положительные срабатывания. Это приводит к тому, что иногда программисты слишком спешат посчитать показавшееся им неубедительное сообщение ложным. Хочется предостеречь от спешки и постараться подходить к изучению сообщений с вниманием.

Кстати, у нас уже были забавные статьи на подобную тему, например:

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

  2. Один день из жизни разработчика PVS-Studio, или отладка диагностики, которая оказалась внимательнее трёх программистов.

  3. Ложные срабатывания в PVS-Studio: как глубока кроличья нора.

Приятного прочтения. И приглашаем попробовать PVS-Studio в деле.

Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Vladislav Stolyarov. How a PVS-Studio developer defended a bug in a checked project.

Комментарии (17)


  1. technic93
    01.11.2021 16:23
    +3

    Судя по std:: это С++ (а на Си), в таких случаях нужно писать абстракцию для буффера, который может быть аллоцирован на стеке или куче. Тогда не надо думать об освобождении памяти при написании логики, потомучто об этом позаботиться RAII. Уверен в бусте уже есть что-то готовое.


    1. Vindicar
      01.11.2021 16:29

      Интересно, чем стандартный vector не подошёл.


      1. technic93
        01.11.2021 16:48

        он будет инициализировать всё нулями


        1. ncpuma
          02.11.2021 13:28

          Нет, не будет. Есть конструктор без инициализации. Ну или можно конструктор по умолчанию использовать.


          1. technic93
            02.11.2021 13:40

            Нету конструктора без инициализации, есть конструктор с инициализацией-по-умолчанию n элементов, что для char инициализирует нулём. Но можно сделать свою обёртку для char (или опять же взять из буста).

            На самом деле я немного смешал разные проблемы, как избежать забиванием нулей это один вопрос. Брать std::vector и std::array вместо си ужасов это другой вопрос, и третий вопрос это взять готовую абстракцию для помеси array и vector.


            1. technic93
              02.11.2021 14:16

              апд. как раз вектор делает value инициализацию, а не default - запутался в этой идиотской терминологии


            1. ncpuma
              03.11.2021 06:52
              +1

              Да, в vector инициализация происходит в аллокаторе, но что мешает поставить свой аллокатор без инициализации?

              http://stackoverflow.com/questions/15097783/value-initialized-objects-in-c11-and-stdvector-constructor/15119665#15119665


              1. technic93
                03.11.2021 12:20

                это хорошее решение


    1. tangro
      01.11.2021 16:50

      Даже самый банальный std::string имеет такую оптимизацию: небольшие строки размещаются на стеке, а большие - в куче. Можно было бы это использовать ну или скопипастить код и поправить под свои задачи.


      1. technic93
        01.11.2021 16:55
        +1

        В std:string на порядок меньше (чем 512 в примере) елементов на стеке. Зависит от реализации, но std::string использует управляющий блок (ptr + size + capacity) чтобы хранить в нём котроткие строки. Это даёт грубо говоря максимум 3 * 8 байт, но посколько что-то всё таки надо хранить для управления, то в gcc выходит 15 байт.
        Поэтому копировать оттуда реализацию нету никакого смысла.


    1. Revertis
      01.11.2021 17:51
      +3

      Вспомнилась статья о том, как отличить сишника от плюсовика :)


  1. svistkovr
    01.11.2021 18:25
    +6

    в куске кода как минимум 2 потенциальные утечки

    на 512 и более байт:

    //...
    message_buf = std::malloc
    message_len = FormatLogMessageForDisplay
    //...
    if (message_len <= 0)
        return;

    и 2048 и более байт:

    //...
    wmessage_buf = std::malloc
    wmessage_buflen = MultiByteToWideChar
    //...
    if (wmessage_buflen <= 0)
        return;

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


    1. unsignedchar
      02.11.2021 10:22
      +4

      Ручное управление памятью + несколько return. (Жаль что goto отменили). Что может пойти не так?


    1. pakager
      02.11.2021 13:24

      В первом случае утечки не будет: если if ((message_len = FormatLogMessageForDisplay( ... вернет значение <= 0, то malloc не отработает и освобождать ничего не нужно.


      Во втором случае — да, действительно может быть утечка, если wmessage_buflen = MultiByteToWideChar( вернет 0. Кстати, MultiByteToWideChar(...) по документации не может вернуть отрицательное число.


      1. svistkovr
        03.11.2021 05:20

        message_len = FormatLogMessageForDisplay вызывается дважды в коде до malloc и после.

        wmessage_buflenтам 3 присваивания :
        1)int wmessage_buflen = countof(wbuf) - 1; // wmessage_buflen==511
        2)wmessage_buflen = message_len; //если message_len<=0 то сюда точно не дойдёт
        3)wmessage_buflen = MultiByteToWideChar

        так зачем нужна проверка после MultiByteToWideChar , если по документации не может вернуть отрицательное число или 0?


        1. pakager
          03.11.2021 13:58

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


          MultiByteToWideChar может вернуть 0, я говорил только, что не может вернуть отрицательное число.


          Хотя согласен — это опасный стиль программирования, и потенциально приводит к ошибкам.


  1. ncr
    02.11.2021 19:05
    +6

    Там в середине под условием есть динамическое выделение памяти под сообщение функцией malloc.
    Вам, наверное, интересно, что это за malloc и откуда он взялся.

    Не интересно, совсем. Если в коде есть

    wchar_t wbuf[512];
    ...
    std::free(wbuf);

    то это баг, независимо от того, что там за malloc и что там за условия.