GCC 16.2 и clang: неопределённое поведение запускает не вызванный код
Автор блога blog.habets.se продолжает свой прошлый текст о неопределённом поведении (UB) в C. Часть читателей возражала: UB якобы безобидно, мы знаем, что сделает код, и в худшем случае получим segfault (аварийное завершение). Автор отвечает коротко: «Это неверно», и приводит три примера на C++ с ассемблерным выходом компиляторов.
Первый пример. Есть volatile-переменная device_command, которую предлагается считать устройством, отображённым в память и управляющим моторами и шестерёнками. Функция serve_hot_chocolate() записывает в неё 42, а затем вызывает std::unreachable(), по определению это вызов UB в заданной точке. Рядом лежит функция explode(), которая записывает в device_command значение 3. GCC 16.2 с флагом -O1 после записи 42 вообще не выдаёт инструкцию возврата (ret) для serve_hot_chocolate(), поэтому выполнение «проваливается» в следующую в памяти функцию, explode(), хотя её никто не вызывал. Автор называет это «взрывающимся шоколадом».
Второй пример. Некоторые утверждали, что разыменование нулевого указателя безобидно: код просто обратится к адресу ноль, и если там ничего не отображено, будет segfault. Автор заменяет std::unreachable() на int* ptr = nullptr; *ptr = 99;. GCC выдаёт запись 42, затем запись нуля (не 99) по адресу ноль и инструкцию ud2 (недопустимая инструкция), которая роняет программу. Число 99 в сгенерированной программе не встречается вовсе. Автор лишь предполагает, что запись по нулевому адресу добавлена ради более понятных сообщений отладчика. Clang, проверенный на godbolt, снова даёт «взрывающийся шоколад»: после записи 42 идёт код explode().
Третий пример. Если к нулевому указателю прибавить ptr += 10, то UB возникает по трём причинам: нулевой указатель нельзя использовать ни для чего, кроме сравнения с другими указателями; даже если бы в этом месте был допустимый объект, создание указателя за его пределами (или дальше позиции «за последним элементом» массива), UB; и даже если бы это было допустимо, затем указатель разыменовывается. Здесь GCC выдал запись 99 по адресу 40 и ret, то есть сделал то, чего наивно ожидают от кода. Автор называет это везением.
Почему компиляторы не распознали, что *(ptr + 10) = 99, такое же UB, как *ptr = 99? Автор считает вопрос неправильным: ни один компилятор не обязан вообще «обнаруживать» UB. По его прочтению, GCC и clang рассуждают так: разыменование нулевого указателя произойти не может, значит, можно перестать выдавать инструкции для этой функции. Из-за проблемы остановки компилятор в общем случае и не знает, выполнится ли код когда-либо.
Итог: для UB в одном и том же месте автор показал три разных поведения, выполнить то, что окажется следом в памяти; записать по нулевому адресу фактически случайное значение и намеренно упасть; сделать то, чего наивно ждёшь. Его вывод: UB может сделать что угодно. Заканчивает он шуткой, что некоторые не верят в «носовых демонов» (nasal demons), устоявшийся в среде программистов образ того, что UB позволяет вообще всё.
Ключевые факты
- После std::unreachable() GCC 16.2 с -O1 не выдал ret для serve_hot_chocolate(), и выполнение провалилось в соседнюю функцию explode(), которую никто не вызывал.
- При записи 99 по нулевому указателю GCC сгенерировал запись нуля (не 99) по адресу ноль и инструкцию ud2, которая роняет программу; clang в этом случае тоже «взорвал шоколад».
- Вариант с ptr += 10 тоже UB (по трём причинам), но GCC скомпилировал его как обычную запись 99 по адресу 40, автор называет это везением.
- Автор подчёркивает: ни один компилятор не обязан обнаруживать UB, а предположение «такого не бывает» позволяет ему отбросить код.
- Для UB в одном и том же месте показаны три разных исхода, поэтому ожидание «в худшем случае segfault» неверно.
Почему это важно
Распространено мнение, что UB в худшем случае приводит к аварийному завершению. Короткие примеры с реальным ассемблерным выходом GCC 16.2 и clang показывают обратное: компилятор может вовсе не выдать возврат из функции, и управление уйдёт в произвольный соседний код. Для программ, управляющих железом, это принципиальная разница, хотя в тексте устройство с моторами, условный пример, а не реальный случай.
Кому это важно
Разработчикам на C и C++, особенно тем, кто пишет низкоуровневый код, работает с указателями и памятью, а также тем, кто спорит о том, «можно ли положиться» на предсказуемое поведение UB на конкретном компиляторе.
Как это применить
Примеры можно воспроизвести самостоятельно: GCC 16.2 с -O1 и clang на godbolt, код приведён в статье целиком. Практический вывод автора, не рассчитывать на то, что UB даст какой-то конкретный результат: исход зависит от компилятора и даже от мелкого изменения кода, как показывает разница между *ptr и *(ptr + 10).
Можно ли доверять
Это личный блог, а не рецензируемое исследование; автор в тексте не назван, указан лишь домен blog.habets.se. Ассемблерный вывод приведён прямо в тексте, его можно проверить. Объяснения автора, что именно «думают» компиляторы, и догадка про отладчик, осторожные предположения, а не подтверждённые разработчиками факты. Для GCC указаны версия 16.2 и -O1; для clang версия и флаги не названы.
Риски и подводные камни
Результаты относятся к конкретным версиям и уровню оптимизации: на других компиляторах и флагах вывод может быть иным, а в тексте проверен только -O1 для GCC. Вариант, где код «сработал как ожидалось», опасен тем, что создаёт ложное чувство безопасности. Пример с устройством, отображённым в память, условный и показывает лишь возможный масштаб последствий.
«Ни один компилятор не обязан вообще «обнаруживать» UB.»
— автор блога blog.habets.se