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