Должен признаться, я питаю слабость к классическому DOOM. Несмотря на то что игре уже стукнул 31 год, в неё всё так же весело играть (хотя я в этом не очень силён) или смотреть, как играют другие (тут я куда сильнее). А благодаря открытому исходному коду наслаждаться ею можно на любой современной платформе: от десктопа и смартфона до цифровых камер, осциллографов и вообще всего, что только можно вообразить. В силу разных обстоятельств я стал сопровождать несколько пакетов, связанных с DOOM, в Fedora Linux.

За несколько месяцев до каждого нового релиза проект Fedora Linux проводит «массовую пересборку» (Mass Rebuild) всех своих пакетов. Это полезная процедура: она гарантирует ABI-совместимость, обновляет статически линкованные зависимости, позволяет использовать новые оптимизации компилятора, средства защиты кода и так далее. Релиз Fedora Linux 42 намечен на середину апреля, так что время массовой пересборки пришло. И, как это часто бывает, не все мои пакеты пережили её без потерь. Одним из «павших» оказался chocolate-doom.
Минус на минус не всегда даёт плюс
Что ж, приступим. Первым делом нужно заглянуть в логи сборки.
gcc -DHAVE_CONFIG_H -I. -I../.. -I../../src -I/usr/include/SDL2 -D_GNU_SOURCE=1 -D_REENTRANT -I/usr/include/SDL2 -D_GNU_SOURCE=1 -D_REENTRANT -I/usr/include/SDL2 -D_GNU_SOURCE=1 -D_REENTRANT -O2 -g -Wall -Wdeclaration-after-statement -Wredundant-decls -O2 -flto=auto -ffat-lto-objects -fexceptions -g -grecord-gcc-switches -pipe -Wall -Werror=format-security -Wp,-U_FORTIFY_SOURCE,-D_FORTIFY_SOURCE=3 -Wp,-D_GLIBCXX_ASSERTIONS -specs=/usr/lib/rpm/redhat/redhat-hardened-cc1 -fstack-protector-strong -specs=/usr/lib/rpm/redhat/redhat-annobin-cc1 -mbranch-protection=standard -fasynchronous-unwind-tables -fstack-clash-protection -fno-omit-frame-pointer -mno-omit-leaf-frame-pointer -I/usr/include/SDL2 -D_GNU_SOURCE=1 -D_REENTRANT -I/usr/include/libpng16 -DWITH_GZFILEOP -I/usr/include/pipewire-0.3 -I/usr/include/spa-0.2 -D_REENTRANT -I/usr/lib64/pkgconfig/../../include/dbus-1.0 -I/usr/lib64/pkgconfig/../../lib64/dbus-1.0/include -I/usr/include/libinstpatch-2 -I/usr/include/glib-2.0 -I/usr/lib64/glib-2.0/include -I/usr/include/sysprof-6 -pthread -I/usr/include/opus -D_DEFAULT_SOURCE -D_XOPEN_SOURCE=600 -c -o deh_bexstr.o deh_bexstr.c In file included from ../../src/sha1.h:21, from ../../src/deh_defs.h:21, from deh_bexstr.c:22: ../../src/doomtype.h:113:5: error: cannot use keyword ‘false’ as enumeration constant 113 | false, | ^~~~~ ../../src/doomtype.h:113:5: note: ‘false’ is a keyword with ‘-std=c23’ onwards
Ого, ошибка компиляции. После многолетней критики за невнятные сообщения об ошибках, GCC за последнее время сильно продвинулся в этом плане. Теперь текст ошибки вместе с пояснением чётко указывает на проблему.
В коде движка chocolate-doom объявляет собственный булев тип:
#if defined(__cplusplus) || defined(__bool_true_false_are_defined) typedef bool boolean; #else typedef enum { false, true } boolean; #endif
То есть для C++ используется встроенный тип bool, а для C — кастомное перечисление. Со старыми стандартами C это работало отлично: в C89 булева типа не было вовсе, а в C99 появился _Bool (хотя можно было подключить <stdbool.h>, где макросы bool, true и false делали код симпатичнее). Но в C23 тип _Bool переименовали в bool, и все три слова (bool, true, false) стали полноценными ключевыми словами языка.
Логично, что кастомный тип конфликтует с ключевыми словами. Но возникает вопрос: почему это сломалось именно сейчас, если несколько месяцев назад всё было в порядке? Почему код вдруг решил собираться по стандарту C23?
Совершенно стандартные изменения
Как я уже упоминал, одна из целей массовой пересборки — убедиться, что код в дистрибутиве совместим с современными компиляторами. Если взглянуть на историю версий GCC, можно заметить закономерность: последние десять лет мажорные версии выходят раз в год, где-то в апреле-мае. Это идеально совпадает с графиком релизов Fedora и делает её отличной площадкой для тестирования пре-релизов GCC на огромном массиве реального кода.
Этот год не стал исключением: GCC 15.0.1 приземлился в Fedora Rawhide («вечная альфа») всего за несколько часов до начала массовой пересборки. Среди изменений этой версии есть одно критически важное для нас: стандарт C по умолчанию был изменён с -std=gnu17 на -std=gnu23. И действительно, если внимательно изучить лог компиляции, можно заметить отсутствие флага -std=, который бы явно задавал стандарт.
Что делать? На ум пришли три варианта:
Явно выставить стандарт C17 или старее, чтобы код продолжал использовать кастомный
enum.Подправить
#ifdef, чтобы в режиме C23 использовался встроенный типbool.Переименовать элементы перечисления в
False/Trueи перелопатить весь код.
Первый вариант — самый простой, но это лишь попытка отложить проблему на потом — плюс встаёт вопрос, какой именно стандарт указывать. Второй вариант показался мне наиболее правильным и идиоматичным. Третий — самым безопасным, но неимоверно нудным. Немного поразмыслив, я выбрал второй путь.
--- a/src/doomtype.h +++ b/src/doomtype.h @@ -100,9 +100,9 @@ #include <inttypes.h> -#if defined(__cplusplus) || defined(__bool_true_false_are_defined) +#if defined(__cplusplus) || defined(__bool_true_false_are_defined) || (__STDC_VERSION__ >= 202311L) -// Use builtin bool type with C++. +// Use builtin bool type with C++ and C23. typedef bool boolean;
Патч оказался элементарным, и после его применения пакет в Fedora собрался успешно. Довольный собой, я отправил Pull Request в основной репозиторий проекта (upstream).
Движок делает «бум»
Моё предложение вызвало дискуссию среди мейнтейнеров. В итоге они решили, что лучше всего будет задекларировать проект как написанный на C99. Один из разработчиков подготовил свой PR:
--- a/src/doomtype.h +++ b/src/doomtype.h @@ -99,12 +99,11 @@ // стандарт и определен для включения stdint.h, поэтому подключаем его. #include <inttypes.h> +#include <stdbool.h> #if defined(__cplusplus) || defined(__bool_true_false_are_defined) - -// Используем встроенный тип bool в C++. - -typedef bool boolean; +typedef int boolean; #else
Добавление #include <stdbool.h> вполне логично — в C99 этот заголовок гарантированно есть и должен предоставлять определения для bool, true и false. Однако изменение typedef выглядело странно: получалось, что несмотря на переход на C99, код всё равно будет хранить булевы значения как int вместо честного bool / _Bool. Это привело к новому обсуждению:
<turol>
Явно задать стандарт — это нормально, но менять include'ы и тип boolean не стоит.<fabiangreffrath>
Chocolate Doom даже не запускается, если оставить#typedef bool boolean.<suve>
Что значит «даже не запускается»? Падает сразу? Включение<stdbool.h>добавляет макрос#define bool _Bool, так чтоtypedef bool booleanозначает использование типа_Boolиз C99.<fabiangreffrath>
Вылетает с ошибкой:R_InitSprites: Sprite TROO frame I has rotations and a rot=0 lump.
Хм. Выходит, использование _Bool для булевых значений заставляет движок падать при запуске. Любопытно — давайте разбираться! По тексту ошибки легко найти место в коде:
if (sprtemp[frame].rotate == false) I_Error ("R_InitSprites: Sprite %s frame %c has rotations " "and a rot=0 lump", spritename, 'A'+frame);
Что здесь происходит? Опущу лишние детали и сразу перейду к сути:
Код находится внутри функции
R_InstallSpriteLump().frame— аргумент функции. Он не помечен какconst, но внутри не меняется.sprtemp— глобальная переменная, массив структурspriteframe_t(29 элементов).Поле
.rotateв этой структуре имеет типboolean.
Итак, проблема в следующем: когда boolean — это кастомный enum, код работает как надо и условие ошибки ложно. Но если использовать _Bool, условие внезапно становится истинным, и программа завершается. Звучит подозрительно... Попробуем заглянуть в память через gdb. Начнём с работающей версии (с enum).
$ gdb ./build/src/chocolate-doom--enum [...] (gdb) break src/doom/r_things.c:138 Breakpoint 1 at 0x44f822: file chocolate-doom/src/doom/r_things.c, line 138. (gdb) run [...] Thread 1 "chocolate-doom" hit Breakpoint 1, R_InstallSpriteLump (lump=1242, frame=0, rotation=1, flipped=false) at chocolate-doom/src/doom/r_things.c:138 138 if (sprtemp[frame].rotate == false) (gdb) print sprtemp[frame] $1 = {rotate = (true | unknown: 0xfffffffe), lump = {-1, -1, -1, -1, -1, -1, -1, -1}, flip = "\377\377\377\377\377\377\377\377"} (gdb) step 142 sprtemp[frame].rotate = true;
Так, наше булево значение заполнено... э-э, чем? Запись в GDB поначалу сбивает с толку. Оказывается, раз тип — это enum, GDB пытается быть полезным и показывает, как значение можно собрать из допустимых вариантов через побитовое «ИЛИ» (правда, в данном случае получается ерунда). В любом случае, true — это 1, а побитовое «ИЛИ» с «неизвестным» значением дает 0xffffffff. Это наводит на мысль, что поле инициализируется записью -1 где-то раньше. И действительно, изучая бэктрейс, в вызывающей функции мы находим вызов memset(sprtemp, -1, sizeof(sprtemp));.
Отлично, одной загадкой меньше. На нашей точке останова происходит простое сравнение 0xffffffff и 0x0, что дает false. Логично. Теперь посмотрим на версию с _Bool.
$ gdb ./build/src/chocolate-doom--bool [...] (gdb) break src/doom/r_things.c:138 Breakpoint 1 at 0x44f822: file chocolate-doom/src/doom/r_things.c, line 138. (gdb) run [...] Thread 1 "chocolate-doom" hit Breakpoint 1, R_InstallSpriteLump (lump=1242, frame=0, rotation=1, flipped=false) at chocolate-doom/src/doom/r_things.c:138 138 if (sprtemp[frame].rotate == false) (gdb) print sprtemp[frame] $1 = {rotate = 255, lump = {-1, -1, -1, -1, -1, -1, -1, -1}, flip = "\377\377\377\377\377\377\377\377"}
Размер boolean уменьшился — с 4 байт до 1. В остальном мы в той же ситуации: поле инициализировано как -1 (что для 1 байта превращается в 255), и программа собирается проверить, верно ли, что 255 == 0.
(gdb) step 139 I_Error ("R_InitSprites: Sprite %s frame %c has rotations "
Не понял... ч т о ?
Нужно копнуть глубже
Да, результат оказался неожиданным. Подумав, что в коде может быть какое-то скрытое взаимодействие, которое я упустил, я решил воспроизвести проблему на маленькой программе.
#include <stdio.h> #include <string.h> #ifdef DUPA #include <stdbool.h> typedef bool boolean; #else typedef enum { false, true } boolean; #endif boolean some_var[30]; int main(void) { memset(some_var, -1, sizeof(some_var)); some_var[0] = false; some_var[1] = 500; for(int i = 0; i <= 2; ++i) { if(some_var[i] == false) printf("some_var[%d] is false\n", i); if(some_var[i] == true) printf("some_var[%d] is true\n", i); printf("value of some_var[%d] is %d\n", i, some_var[i]); } return 0; }
И действительно, поведение в точности совпало с тем, что я видел в игре:
$ gcc -o booltest ./booltest.c $ ./booltest some_var[0] is false value of some_var[0] is 0 some_var[1] is true value of some_var[1] is 1 value of some_var[2] is -1 $ gcc -DDUPA -o booltest ./booltest.c $ ./booltest some_var[0] is false value of some_var[0] is 0 some_var[1] is true value of some_var[1] is 1 some_var[2] is false some_var[2] is true value of some_var[2] is 255
Каким-то образом установка значения _Bool в 255 привела к тому, что оно стало одновременно и true, и false. Обычными манипуляциями с C-кодом ответов было не добиться, пришло время заглянуть в ассемблер. Для этого я воспользовался Godbolt (Compiler Explorer).

Сгенерированные инструкции немного отличались. Это была бы отличная новость, если бы не одна маленькая деталь: я совершенно не знаю ассемблер x86.
К счастью, есть такая замечательная штука, как интернет. Сверившись с источниками, я перевел ассемблерные инструкции на человеческий язык. Во всех четырех сценариях код начинает с загрузки some_value[i] в регистр eax.
boolean — enum, проверка на true
CMP eax, 1Тут всё просто: значение регистра сравнивается с
1. Если они равны, устанавливается «флаг нуля» (ZF), иначе флаг сбрасывается.JNE .L4Проверяет ZF и, если флаг не установлен, выполняет переход, перепрыгивая через вызов
printf().
Всё логично: вместо «сделай, если равно 1», получилось «пропусти, если не равно 1». Вполне стандартно для ассемблера.
boolean — enum, проверка на false
TEST eax, eaxВычисляет побитовое «И» регистра с самим собой. ZF устанавливается, если результат равен нулю.
JNE .L3Если ZF не установлен, выполняет переход и пропускает вызов
printf().
Здесь чуть хитрее: вместо «сравни с нулем» используется TEST, что, скорее всего, какая-то микрооптимизация. Но суть та же — пропускаем printf для любого ненулевого значения.
boolean — _Bool, проверка на true
TEST al, alСнова побитовое «И» значения с самим собой, ZF ставится при нулевом результате.
al— это 8-битный регистр (младшие 8 битeax), так как булевы значения теперь занимают всего 1 байт.JE .L4Переход, если ZF установлен.
А вот тут становится интересно! Похоже, в этой версии логика сменилась с «пропусти, если не равно 1» на «пропусти, если равно 0».
boolean — _Bool, проверка на false
XOR eax, 1Выполняет побитовое исключающее «ИЛИ» значения с единицей. Это эффективно инвертирует младший бит:
0превращается в1, а1в0.TEST al, alСнова побитовое «И», ZF устанавливается, если результат равен нулю.
JE .L3Переход, если ZF установлен.
Ох... Код инвертирует младший бит и прыгает, если результат стал нулем. То есть логика превратилась в «пропусти, если значение было равно 1».
Подведем итоги
Когда boolean — это enum, компилятор ведет себя ожидаемо: условие == false проверяет на равенство нулю, а == true — на равенство единице.
Однако, когда boolean — это на самом деле _Bool, проверка == false трансформируется в != 1, а проверка == true — в != 0. С точки зрения чистой булевой логики это абсолютно верно. Но если в переменной лежит 255, начинается веселье: так как 255 не равно ни 0, ни 1, оба условия проходят!
Неужели на это есть правило?
Теперь, когда я понял, что происходит, остался последний вопрос: почему? Запихивание некорректного значения в тип и получение странного результата — предсказуемая ситуация, особенно в C. Я был почти уверен, что столкнулся с любимым аспектом этого языка — неопределенным поведением (Undefined Behaviour, UB). Сборка с UBSan быстро подтвердила гипотезу.
$ gcc -DDUPA -fsanitize=undefined -o booltest ./booltest.c $ ./booltest some_var[0] is false value of some_var[0] is 0 some_var[1] is true value of some_var[1] is 1 booltest.c:22:14: runtime error: load of value 255, which is not a valid value for type '_Bool' booltest.c:23:14: runtime error: load of value 255, which is not a valid value for type '_Bool' some_var[2] is true booltest.c:24:69: runtime error: load of value 255, which is not a valid value for type '_Bool' value of some_var[2] is 1
На этом можно было бы закончить, но мне было любопытно, какой именно пункт стандарта нарушен. Я нашёл PDF-версию стандарта C99. К сожалению, Приложение J («Вопросы переносимости»), где перечислены все виды неопределенного, неспецифицированного или зависящего от реализации поведения, ничего прямо не говорит о некорректных значениях _Bool.
После долгих поисков я нашел ответ в разделе 6.2.6 «Представление типов». Параграф 6.2.6.1.5 гласит:
Определенные представления объектов могут не представлять значение соответствующего типа. Если хранящееся значение объекта имеет такое представление и считывается выражением (lvalue), которое не имеет символьного типа (character type), поведение является неопределенным.
Это в точности наш случай: объект типа _Bool содержал значение, не являющееся допустимым для _Bool, и считывался как часть выражения, не трактующего его как char. Так что код однозначно находился на территории Undefined Behaviour. К счастью, в этот раз «носовые демоны» пощадили мои ноздри.
