Введение
Непредсказуемый результат при выполнении программы
В компьютерном программировании неопределенное поведение (UB) — это результат выполнения программы, поведение которой в спецификации языка, которому соответствует компьютерный код, предписано как непредсказуемое. Это отличается от не указанного поведения, для которого спецификация языка не предписывает результат, и от поведения, определяемого реализацией, которое отсылает к документации другого компонента платформы (например, ABI или документации компилятора). В сообществе программистов на языке C неопределенное поведение иногда юмористически называют «носовыми демонами», отсылая к сообщению на форуме comp.std.c, где неопределенное поведение объяснялось тем, что компилятор может делать все, что угодно, даже «заставить демонов вылетать из носа».
Обзор
Некоторые языки программирования позволяют программе работать иначе или даже иметь иной поток управления, чем в исходном коде, при условии, что наблюдаемые пользователем побочные эффекты остаются прежними, если неопределенное поведение никогда не возникает во время выполнения программы. Неопределенное поведение — это перечень условий, которым программа не должна соответствовать. В ранних версиях C основным преимуществом неопределенного поведения было создание высокопроизводительных компиляторов для широкого спектра машин: конкректная конструкция могла быть сопоставлена со специфической возможностью машины, и компилятору не требовалось генерировать дополнительный код для времени выполнения, чтобы адаптировать побочные эффекты к семантике, заданной языком. Исходный код программы писался с учетом особенностей конкретного компилятора и поддерживаемых им платформ. Однако прогрессивная стандартизация платформ снизила это преимущество, особенно в новых версиях C. Теперь случаи неопределенного поведения обычно представляют собой однозначные ошибки в коде, например, обращение к элементу массива за его пределами. По определению, среда выполнения может предполагать, что неопределенное поведение никогда не происходит; следовательно, некоторые недопустимые условия не требуется проверять. Для компилятора это также означает, что различные преобразования программы становятся допустимыми или упрощаются доказательства их корректности, что позволяет проводить различные виды оптимизаций, корректность которых зависит от предположения, что состояние программы никогда не соответствует недопустимому условию. Компилятор также может удалять явные проверки из исходного кода, не уведомляя программиста; например, обнаружение неопределенного поведения путем проверки его возникновения не гарантируется, по определению. Это затрудняет или делает невозможным программирование переносимого механизма защиты от сбоев (непереносимые решения возможны для некоторых конструкций). Современная разработка компиляторов обычно оценивает и сравнивает производительность компиляторов с помощью тестов, разработанных для микрооптимизаций, даже на платформах, которые в основном используются на рынке настольных компьютеров и ноутбуков общего назначения (таких как amd64). Поэтому неопределенное поведение предоставляет широкие возможности для повышения производительности компилятора, поскольку исходный код конкретного оператора может быть сопоставлен с чем угодно во время выполнения. Для C и C++ компилятор в этих случаях может выдавать диагностику времени компиляции, но не обязан это делать: реализация считается корректной независимо от ее действий в таких случаях, аналогично безразличным значениям в цифровой логике. Программист несет ответственность за написание кода, который никогда не вызывает неопределенное поведение, хотя компиляторы могут выдавать диагностику при его возникновении. В современных компиляторах есть флаги, которые позволяют включить такую диагностику, например, `fsanitize=undefined` включает "санитайзер неопределенного поведения" (UBSan) в gcc 4.9 и clang. Однако этот флаг не является включенным по умолчанию, и его активация — это выбор человека, собирающего код. В определенных обстоятельствах могут существовать конкретные ограничения на неопределенное поведение. Например, спецификации набора инструкций процессора могут оставлять поведение некоторых форм инструкций неопределенным, но если процессор поддерживает защиту памяти, то спецификация, вероятно, будет включать общее правило, согласно которому ни одна инструкция, доступная пользователю, не должна приводить к нарушению безопасности операционной системы; таким образом, реальный процессор может повредить пользовательские регистры в ответ на такую инструкцию, но не сможет, например, переключиться в режим супервизора. Среда выполнения также может налагать некоторые ограничения или предоставлять гарантии в отношении неопределенного поведения, если инструментарий или среда выполнения явно документируют, что конкретные конструкции в исходном коде сопоставляются с конкретными четко определенными механизмами, доступными во время выполнения. Например, интерпретатор может документировать определенное поведение для некоторых операций, которые не определены в спецификации языка, в то время как другие интерпретаторы или компиляторы для того же языка могут этого не делать. Компилятор генерирует исполняемый код для конкретной ABI, заполняя семантические пробелы способами, зависящими от версии компилятора: документация для этой версии компилятора и спецификация ABI могут предоставлять ограничения на неопределенное поведение. Опора на эти детали реализации делает программное обеспечение непереносимым, но переносимость может быть не важна, если программное обеспечение не предназначено для использования вне конкретной среды выполнения. Неопределенное поведение может привести к сбою программы или даже к ошибкам, которые трудно обнаружить и заставляют программу казаться работающей нормально, например, к беззвучной потере данных и генерации неверных результатов.
Риски
В стандартах C и C++ существует множество случаев неопределенного поведения, предоставляющих большую свободу в реализации компиляторов и выполнении проверок во время компиляции, но при этом приводящих к неопределенному поведению во время выполнения, если оно возникает. В частности, стандарт ISO для C содержит приложение, перечисляющее распространенные источники неопределенного поведения. Более того, компиляторы не обязаны выявлять код, использующий неопределенное поведение. Поэтому программисты, даже опытные, часто невольно или из-за незнания обширных правил языка (которые могут занимать сотни страниц) полагаются на неопределенное поведение. Это может приводить к ошибкам, проявляющимся при использовании другого компилятора или иных настроек. Тестирование или фаззинг с включенными динамическими проверками неопределенного поведения, например, санитайзерами Clang, может помочь выявить неопределенное поведение, которое не обнаруживается компилятором или статическими анализаторами. Неопределенное поведение может стать причиной уязвимостей в программном обеспечении. Например, переполнения буфера и другие уязвимости в основных веб-браузерах обусловлены неопределенным поведением. Когда разработчики GCC в 2008 году изменили компилятор, исключив некоторые проверки переполнения, основанные на неопределенном поведении, CERT выпустил предупреждение относительно новых версий компилятора. Linux Weekly News отметил, что аналогичное поведение наблюдалось в PathScale C, Microsoft Visual C++ 2005 и ряде других компиляторов; впоследствии предупреждение было расширено, чтобы охватить различные компиляторы.