В сентябре мы рассматривали релиз 86Box v5.0, приуроченный к тридцати годам со дня выхода в розничную продажу Windows 95, и пообещали показать ещё кое-что. О чём мы сознательно умолчали, и почему оставили находку для отдельной статьи?

Освежим память

86Box — это "аппаратно-точный" эмулятор IBM PC и совместимых с его архитектурой компьютеров. В предыдущей статье мы рассмотрели потенциальные баги, опираясь в том числе на техническую документацию эмулируемых компонентов, и упомянули, что проект был собран в релизной конфигурации с отладочной ��нформацией (RelWithDebInfo). Теперь она нам пригодится, потому что у нас взорвался виртуальный танталовый конденсатор в процессе изучения срабатываний PVS-Studio.

Сегодня в программе

Сначала пропустим в студию главного героя сегодняшнего выпуска — срабатывание анализатора PVS-Studio:

V575 The null pointer is passed into 'fseek' function. Inspect the first argument. vid_ati_eeprom.c 61

void
ati_eeprom_load_mach8(ati_eeprom_t *eeprom, char *fn, int mca)
{
    FILE *fp;
    ....
    fp   = nvr_fopen(eeprom->fn, "rb");
    size = 128;
    if (!fp) {
        if (mca) {
            (void) fseek(fp, 2L, SEEK_SET);             // <=
            memset(eeprom->data + 2, 0xff, size - 2);
            fp = nvr_fopen(eeprom->fn, "wb");
            fwrite(eeprom->data, 1, size, fp);
    ....
}

Нам надо загрузить данные, сохранённые в NVRAM видеоадаптера, и храним мы их в файле в двоичном виде. Если файла нет, то надо создать в нём "начальные" данные. Вот как раз сценарий, когда файла нет. Мы смещаем указатель на файл, а он нулевой, и получаем разыменование нулевого указателя fp как из палаты мер и весов.

Взглянем подробнее на fseek. Стандарт C11 не определяет требования к первому параметру функции и, соответственно, не гарантирует проверку на NULL. Это значит, что его обработка остаётся на совести разработчиков стандартной библиотеки. В студию приглашаются:

  • GNU glibc;

  • BSD libc из FreeBSD 14.3;

  • Microsoft Universal CRT из Windows SDK 10.0.26100;

  • musl v1.2.5.

Последние две реализации стандартной библиотеки языка C здесь в качестве гостей: 86Box не рассчитан на использование с ними или их совместимость не проверялась. В инструкции по сборке альтернативные реализации тоже не упомянуты. Поэтому начнём с ожидаемых к использованию стандартных библиотек и попросим их повторить те же самые действия над нулевым указателем на файл.

Подаём напряжение

Достаём с воображаемого стеллажа IBM PS/2 model 55SX и "вставляем" в него 2D-ускоритель IBM 8514/A в исполнении ATI.

Первым испытуемым станет собранный с помощью MinGW экземпляр для Windows. Убеждаемся в отсутствии файла NVRAM перед стартом: для этого нужно проверить папку %userprofile%\86Box VMs\<имя виртуальной машины>\nvr на наличие файла ati8514_mca.nvr. Если есть, то удаляем.

Включаем наш агрегат, и...

Ничего не взорвалось! Всё хорошо, файл NVRAM записан, компьютер работает и smoke-тест на glibc окончен. Дефект не обнаружен.

Переходим к FreeBSD. Стандартную библиотеку языка C в этой операционной системе реализует libc. В принципе, это справедливо для всех операционных систем семейства BSD.

Конфигурацию используем ту же самую. Отсутствие файла NVRAM ati8514_mca.nvr проверяем по пути ~/.local/share/86Box/Virtual Machines/<имя виртуальной машины>/nvr. Три, два, один, включаем...

Ну, лучше эту ситуацию опишет только произошедшее давным-давно у Макса Крюкова :)

Открываем зажмуренные после взрыва глаза и смотрим в консоль: у нас подтверждён ненормальный выход!

void VMManagerSystem::launchMainProcess() Full Command:
"/root/86Box/build_freebsd/src/86Box"
("--vmpath", "/root/.local/share/86Box/Virtual Machines/somevm",
 "--vmname",
 "somevm")
Connection received on 86Box.socket.5876c5
Connection disconnected
Abnormal program termination while launching main process:
exit code 11, exit status QProcess::CrashExit

Рядом с исполняемым файлом эмулятора появился дамп ядра. Приглашаем в студию LLDB:

root@freebsd:~/86Box/build_freebsd/src # lldb 86Box -c 86Box.core
(lldb) target create "86Box" --core "86Box.core"
Core file '/root/86Box/build_freebsd/src/86Box.core' (x86_64) was loaded.
(lldb) bt
* thread #1, name = '86Box', stop reason = signal SIGSEGV
  * frame #0: 0x0000000832f880bf
              libc.so.7`_flockfile(fp=0x0000000000000000)
              at _flock_stub.c:65:20
    frame #1: 0x0000000832f8b675
              libc.so.7`fseek(fp=0x0000000000000000, offset=2, whence=0)
              at fseek.c:62:2
    frame #2: 0x00000000018cd964
              86Box`ati_eeprom_load_mach8(eeprom=...., fn=<unavailable>, mca=1)
              at vid_ati_eeprom.c:61:20

Нулевой указатель fp устраивает светошумовое представление: не получается заблокировать файл, потому что нет действительного файлового дескриптора. К сожалению, LLDB очень не хотел работать в реальном времени и падал то с тихим lost connection, то с грохотом и спецэффектами. Поэтому хождения по коду, как в Windows, я показать не смогу.

Эти два случая вполне могут быть багами графической оболочки LLDB...

Изучаем схему включения

Как так вышло, что в одной библиотеке функция работает штатно, а в другой — нет? Взглянем теперь на устройство fseek в glibc и BSD libc. Придётся походить по макросам.

glibc:

1. fp передаётся в макрос CHECK_FILE в fseek.c.

int
fseek (FILE *fp, long int offset, int whence)
{
  int result;
  CHECK_FILE (fp, -1);               // <=
  _IO_acquire_lock (fp);
  result = _IO_fseek (fp, offset, whence);
  _IO_release_lock (fp);
  return result;
}

2. Внутри макроса CHECK_FILE из libioP.h с FILE происходит... великое ничего?

#ifdef IO_DEBUG
# define CHECK_FILE(FILE, RET) do {        \
    if ((FILE) == NULL            \
  || ((FILE)->_flags & _IO_MAGIC_MASK) != _IO_MAGIC)  \
      {                \
  __set_errno (EINVAL);          \
  return RET;            \
      }                \
  } while (0)
#else
# define CHECK_FILE(FILE, RET) do { } while (0)
#endif

Не совсем. Как минимум у нас выставляется ответ -1 в MinGW версии glibc. А вот что происходит в glibc из Devuan 6 "Excalibur"...

Чисто случайно в списке рассылки bugs-devel у Debian нашлось тематическое обсуждение похожей проблемы. Приходим к заключению, что поведение функции ещё зависит от того, как glibc был скомпилирован. Неожиданно и неприятно.

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

(void) fseek(fp, 2L, SEEK_SET);

glibc стерпела такое обращение к себе на MinGW, но на GNU/Linux проявила силу и ударила без предупреждения. И никто не докажет её неправоту, ведь, напомню, поведение функции fseek с нулевым указателем на файл не определено стандартом!

BSD libc:

1. fp передаётся в макрос FLOCKFILE_CANCELSAFE в fseek.c.

int
fseek(FILE *fp, long offset, int whence)
{
  int ret;
  int serrno = errno;

  /* make sure stdio is set up */
  if (!__sdidinit)
    __sinit();

  FLOCKFILE_CANCELSAFE(fp);   // <=
  ret = _fseeko(fp, (off_t)offset, whence, 1);
  FUNLOCKFILE_CANCELSAFE();
  if (ret == 0)
    errno = serrno;
  return (ret);
}

2. fp передаётся в макрос _FLOCKFILE в local.h.

#define  FLOCKFILE_CANCELSAFE(fp)          \
  {                \
    struct _pthread_cleanup_info __cleanup_info__;    \
    if (__isthreaded) {          \
      _FLOCKFILE(fp);          \ // <=
      ___pthread_cleanup_push_imp(      \
          __stdio_cancel_cleanup, (fp),     \
          &__cleanup_info__);        \
    } else {            \
      ___pthread_cleanup_push_imp(      \
          __stdio_cancel_cleanup, NULL,     \
          &__cleanup_info__);        \
    }              \
    {
#define  FUNLOCKFILE_CANCELSAFE()          \
      (void)0;          \
    }              \
    ___pthread_cleanup_pop_imp(1);        \
  }

3. Макрос разворачивается в вызов функции _flockfile в _flock_stub.c.

#ifdef  _FLOCK_DEBUG
#define _FLOCKFILE(x)  _flockfile_debug(x, __FILE__, __LINE__)
#else
#define _FLOCKFILE(x)  _flockfile(x)
#endif

И на третьем шагу у нас тоже пробой по указателю.

void
_flockfile(FILE *fp)
{
    pthread_t curthread = _pthread_self();

    if (fp->_fl_owner == curthread)           // <=
        fp->_fl_count++;
    else {
      ....
    }
}

Что же делать? Не поверите, но не пытаться шерстить по нулевому указателю. fp всё равно дальше по коду переиспользуется для открытия файла на запись. Нужно просто удалить строку.

void
ati_eeprom_load_mach8(ati_eeprom_t *eeprom, char *fn, int mca)
{
    FILE *fp;
    ....
    fp   = nvr_fopen(eeprom->fn, "rb");
    size = 128;
    if (!fp) {
        if (mca) {
            memset(eeprom->data + 2, 0xff, size - 2);
            fp = nvr_fopen(eeprom->fn, "wb");
            fwrite(eeprom->data, 1, size, fp);
    ....
}

Убираем воображаемый паяльник обратно в подставку, собираем "тестовый стенд" ещё раз и три, два, один, включаем...

Есть картинка!

Никаких больше аварийных выключений. Мы пришли к ожидаемо��у для PS/2 model 55SX состоянию с необходимостью настроить BIOS.

Примечательно, что в соседней функции ati_eeprom_load_mach8_vga именно так и сделано, и файл сразу переоткрывается на запись.

А что у других стандартных библиотек?

Паяльник убран, флюс отмыт. Теперь можно и про оставшиеся два аналога стандартной библиотеки языка C поговорить.

Продолжим нашу программу демонстрацией поведения универсальной версией от Microsoft — Microsoft Universal C Runtime. Смотрим в Windows SDK 10.0.26100:

static int __cdecl common_fseek(
    __crt_stdio_stream const stream,
    __int64            const offset,
    int                const whence,
    __crt_cached_ptd_host&   ptd
    ) throw()
{
    _UCRT_VALIDATE_RETURN(ptd, stream.valid(), EINVAL, -1);
    ....
}

extern "C" int __cdecl fseek(
    FILE* const public_stream,
    long  const offset,
    int   const whence
    )
{
  __crt_cached_ptd_host ptd;
  return common_fseek(__crt_stdio_stream(public_stream), offset, whence, ptd);
}

Макрос _UCRT_VALIDATE_RETURN отбивает любую попытку подать в функцию недействительный файловый дескриптор, и в конфигурации Release приложение упадёт с исключением:

Unhandled exception at 0x00007FFAB796CBA8 (ucrtbase.dll) in example.exe: An invalid parameter was passed to a function that considers invalid parameters fatal.

Таким образом, у нас уже есть три варианта исхода с кодом без проверки: "компиляционно-зависимое" у glibc, шок от непроверенных данных у BSD libc и праведное возмущение у UCRT.

Что там у musl?

1. Функция fseek переходит в функцию __fseeko в fseek.c, оттуда в макрос FLOCK для блокировки файла.

int __fseeko(FILE *f, off_t off, int whence)
{
  int result;
  FLOCK(f);                             // <=
  result = __fseeko_unlocked(f, off, whence);
  FUNLOCK(f);
  return result;
}

int fseek(FILE *f, long off, int whence)
{
  return __fseeko(f, off, whence);
}

2. Аргумент f в макросе FLOCK в stdio_impl.h, что является указателем на файловый дескриптор, разыменовывается без валидации.

#define FLOCK(f) int __need_unlock = ((f)->lock>=0 ? __lockfile((f)) : 0)

Повторяется сценарий BSD libc! Там все сговорились что ли? Да нет же. В очередной раз напомню: если что-то не определено стандартом, то полагаться на реализацию вредно для здоровья программы. Другого не дано.

Акт выполненных работ (итоги)

Итак, проблема идентифицирована и устранена, работоспособность графического адаптера в проблемном окружении восстановлена. Нахожд��ние таких проблем средствами статического анализа — весомый аргумент в их пользу, в том числе в сценарии регулярного использования. Вообще, сборку под FreeBSD после выхода 86Box v5.0 чинили несколько раз, и в версии v5.1 уже сходу можно получить рабочую программу.

На этом наш "ремонт" закончен. Благодарю за уделённое время, увидимся в новой статье!

Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Taras Shevchenko. Box of bugs (exploded): perils of cross-platform development.