В сентябре мы рассматривали релиз 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.

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


  1. Jijiki
    05.11.2025 20:50

    нет проверки на NULL получается смещается по нулевому указателю, тоесть перед этой строкой если она была нужна надо было ставить проверку если не NULL наверное, странная ошибка улыбнуло вообще

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

    я сначала подумал это файловая система, но ведь там явно 0 интересно, поидее надо получить адрес ячейки, а он 0 и типо ему надо в этой фс смещение почему-то на будущее наверное

    тоесть это надо это смещение зараннее учесть, чтобы они были пустые или не пустые, получается там где-то должна быть инициализация этого мини образа с учетом этого смещения, наверное