В программировании компьютера неопределенное поведение ( UB ) является результатом выполнения программы, поведение которой предписано непредсказуемым в спецификации языка программирования , на котором написан исходный код . Это отличается от неопределенного поведения , для которого спецификация языка не предписывает результат, и поведения, определяемого реализацией, которое относится к документации другого компонента платформы ( например, ABI или документации транслятора ).
В сообществе программистов на языке C неопределенное поведение можно в шутку назвать « носовыми демонами » после поста comp.std.c , в котором неопределенное поведение объяснялось как возможность компилятору делать все, что он захочет, даже «заставлять демонов вылетать из вашего носа». [1]
Некоторые языки программирования позволяют программе работать по-другому или даже иметь другой поток управления из исходного кода, пока она демонстрирует те же видимые пользователю побочные эффекты , если неопределенное поведение никогда не происходит во время выполнения программы . Неопределенное поведение — это название списка условий, которым программа не должна соответствовать.
В ранних версиях C основным преимуществом неопределенного поведения было создание производительных компиляторов для широкого спектра машин: конкретная конструкция могла быть сопоставлена с машинно-специфической функцией, и компилятору не приходилось генерировать дополнительный код для среды выполнения, чтобы адаптировать побочные эффекты для соответствия семантике, налагаемой языком. Исходный код программы был написан с предварительным знанием конкретного компилятора и платформ, которые он будет поддерживать.
Однако прогрессивная стандартизация платформ сделала это преимуществом, особенно в новых версиях C. Теперь случаи неопределенного поведения обычно представляют собой однозначные ошибки в коде, например, индексацию массива за пределами его границ. По определению, среда выполнения может предполагать, что неопределенное поведение никогда не происходит; поэтому некоторые недопустимые условия не нужно проверять. Для компилятора это также означает, что различные преобразования программы становятся допустимыми или их доказательства корректности упрощаются; это позволяет выполнять различные виды оптимизаций, корректность которых зависит от предположения, что состояние программы никогда не соответствует ни одному такому условию. Компилятор также может удалять явные проверки, которые могли быть в исходном коде, не уведомляя программиста; например, обнаружение неопределенного поведения путем проверки того, произошло ли оно, по определению не гарантирует работу. Это затрудняет или делает невозможным программирование переносимого отказоустойчивого варианта (для некоторых конструкций возможны непереносимые решения).
Текущая разработка компиляторов обычно оценивает и сравнивает производительность компилятора с бенчмарками, разработанными вокруг микрооптимизаций, даже на платформах, которые в основном используются на рынке настольных компьютеров и ноутбуков общего назначения (например, amd64). Таким образом, неопределенное поведение предоставляет достаточно места для улучшения производительности компилятора, поскольку исходный код для определенного оператора исходного кода может быть сопоставлен с чем угодно во время выполнения.
Для C и C++ компилятору разрешено выдавать диагностику во время компиляции в этих случаях, но это не обязательно: реализация будет считаться правильной, что бы она ни делала в таких случаях, аналогично терминам don't care в цифровой логике. Программист несет ответственность за написание кода, который никогда не вызывает неопределенное поведение, хотя реализациям компилятора разрешено выдавать диагностику, когда это происходит. В настоящее время компиляторы имеют флаги, которые включают такую диагностику, например, -fsanitize=undefined
включает «дезинфицирующее средство неопределенного поведения» (UBSan) в gcc 4.9 [2] и в clang . Однако этот флаг не является флагом по умолчанию, и его включение является выбором человека, который строит код.
При некоторых обстоятельствах могут быть определенные ограничения на неопределенное поведение. Например, спецификации набора инструкций ЦП могут оставлять поведение некоторых форм инструкций неопределенным, но если ЦП поддерживает защиту памяти , то спецификация, вероятно, будет включать общее правило, гласящее, что никакая доступная пользователю инструкция не может вызвать дыру в безопасности операционной системы ; поэтому реальному ЦП будет разрешено повреждать пользовательские регистры в ответ на такую инструкцию, но ему не будет разрешено, например, переключаться в режим супервизора .
Платформа выполнения также может предоставлять некоторые ограничения или гарантии на неопределенное поведение, если инструментарий или среда выполнения явно документируют, что определенные конструкции, найденные в исходном коде, сопоставлены с определенными четко определенными механизмами, доступными во время выполнения. Например, интерпретатор может документировать определенное поведение для некоторых операций, которые не определены в спецификации языка, в то время как другие интерпретаторы или компиляторы для того же языка могут этого не делать. Компилятор создает исполняемый код для определенного ABI , заполняя семантический пробел способами, которые зависят от версии компилятора: документация для этой версии компилятора и спецификация ABI могут предоставлять ограничения на неопределенное поведение. Опора на эти детали реализации делает программное обеспечение непереносимым , но переносимость может не быть проблемой, если программное обеспечение не предполагается использовать вне определенной среды выполнения.
Неопределенное поведение может привести к сбою программы или даже к сбоям, которые сложнее обнаружить и из-за которых программа выглядит работающей нормально, например, к скрытой потере данных и получению неверных результатов.
Документирование операции как неопределенного поведения позволяет компиляторам предполагать, что эта операция никогда не произойдет в соответствующей программе. Это дает компилятору больше информации о коде, и эта информация может привести к большему количеству возможностей оптимизации.
Пример для языка Си:
int foo ( unsigned char x ) { int value = 2147483600 ; /* предполагается 32-битный int и 8-битный char */ value += x ; if ( value < 2147483600 ) bar (); return value ; }
Значение x
не может быть отрицательным, и, учитывая, что переполнение знакового целого числа является неопределенным поведением в C, компилятор может предположить, что value < 2147483600
всегда будет ложным. Таким образом if
, оператор, включая вызов функции bar
, может быть проигнорирован компилятором, поскольку тестовое выражение в if
не имеет побочных эффектов , и его условие никогда не будет выполнено. Таким образом, код семантически эквивалентен:
int foo ( unsigned char x ) { int value = 2147483600 ; value += x ; return value ; }
Если бы компилятор был вынужден предположить, что переполнение знакового целого числа имеет циклическое поведение, то приведенное выше преобразование было бы недопустимым.
Такие оптимизации становится трудно обнаружить людям, когда код становится более сложным и происходят другие оптимизации, такие как inlining . Например, другая функция может вызвать указанную выше функцию:
void run_tasks ( unsigned char * ptrx ) { int z ; z = foo ( * ptrx ); while ( * ptrx > 60 ) { run_one_task ( ptrx , z ); } }
Компилятор может оптимизировать -loop while
здесь, применяя анализ диапазона значений : проверяя foo()
, он знает, что начальное значение, на которое указывает , ptrx
не может превышать 47 (поскольку любое большее значение вызовет неопределенное поведение в foo()
); следовательно, начальная проверка *ptrx > 60
всегда будет ложной в соответствующей программе. Идя дальше, поскольку результат z
теперь никогда не используется и foo()
не имеет побочных эффектов, компилятор может оптимизироваться run_tasks()
так, чтобы быть пустой функцией, которая немедленно возвращается. Исчезновение -loop while
может быть особенно удивительным, если foo()
определено в отдельно скомпилированном объектном файле .
Еще одно преимущество от разрешения переполнения знакового целого числа быть неопределенным заключается в том, что это позволяет хранить и манипулировать значением переменной в регистре процессора , который больше, чем размер переменной в исходном коде. Например, если тип переменной, указанный в исходном коде, уже, чем собственная ширина регистра (например, int
на 64-битной машине, распространенный сценарий), то компилятор может безопасно использовать знаковое 64-битное целое число для переменной в машинном коде, который он производит, не изменяя определенного поведения кода. Если программа зависела от поведения переполнения 32-битного целого числа, то компилятору пришлось бы вставлять дополнительную логику при компиляции для 64-битной машины, потому что поведение переполнения большинства машинных инструкций зависит от ширины регистра. [3]
Неопределенное поведение также допускает больше проверок во время компиляции как компиляторами, так и статическим анализом программ . [ необходима ссылка ]
Стандарты C и C++ имеют несколько форм неопределенного поведения, которые предлагают большую свободу в реализациях компилятора и проверках во время компиляции за счет неопределенного поведения во время выполнения, если оно присутствует. В частности, стандарт ISO для C имеет приложение, в котором перечислены общие источники неопределенного поведения. [4] Более того, компиляторы не обязаны диагностировать код, который опирается на неопределенное поведение. Следовательно, программисты, даже опытные, часто полагаются на неопределенное поведение либо по ошибке, либо просто потому, что они не очень хорошо разбираются в правилах языка, которые могут охватывать сотни страниц. Это может привести к ошибкам, которые выявляются при использовании другого компилятора или других настроек. Тестирование или фаззинг с включенными динамическими проверками неопределенного поведения, например, санитайзерами Clang , могут помочь обнаружить неопределенное поведение, не диагностируемое компилятором или статическими анализаторами. [5]
Неопределенное поведение может привести к уязвимостям безопасности в программном обеспечении. Например, переполнение буфера и другие уязвимости безопасности в основных веб-браузерах вызваны неопределенным поведением. Когда разработчики GCC изменили свой компилятор в 2008 году таким образом, что он пропустил некоторые проверки переполнения, которые полагались на неопределенное поведение, CERT выпустил предупреждение против новых версий компилятора. [6] Linux Weekly News указал, что такое же поведение наблюдалось в PathScale C , Microsoft Visual C++ 2005 и нескольких других компиляторах; [7] предупреждение было позже изменено, чтобы предупреждать о различных компиляторах. [8]
Основные формы неопределенного поведения в языке C можно в целом классифицировать следующим образом: [9] нарушения безопасности пространственной памяти, нарушения безопасности временной памяти, переполнение целочисленных значений , нарушения строгого совмещения имен, нарушения выравнивания, непоследовательные модификации, гонки данных и циклы, которые не выполняют ввод-вывод и не завершаются.
В языке C использование любой автоматической переменной до ее инициализации приводит к неопределенному поведению, как и деление целых чисел на ноль , переполнение знакового целого числа, индексация массива за пределами определенных границ (см. переполнение буфера ) или разыменование нулевого указателя . В общем случае любой пример неопределенного поведения оставляет абстрактную исполняющую машину в неизвестном состоянии и приводит к тому, что поведение всей программы становится неопределенным.
Попытка изменить строковый литерал приводит к неопределенному поведению: [10]
char * p = "wikipedia" ; // допустимо в C, устарело в C++98/C++03, неправильно сформировано с C++11 p [ 0 ] = 'W' ; // неопределенное поведение
Целочисленное деление на ноль приводит к неопределенному поведению: [11]
int x = 1 ; return x / 0 ; // неопределенное поведение
Некоторые операции с указателями могут привести к неопределенному поведению: [12]
int arr [ 4 ] = { 0 , 1 , 2 , 3 }; int * p = arr + 5 ; // неопределенное поведение при индексации за пределами границ p = NULL ; int a = * p ; // неопределенное поведение при разыменовывании нулевого указателя
В C и C++ реляционное сравнение указателей на объекты (для сравнения «меньше или больше») строго определено только в том случае, если указатели указывают на элементы одного и того же объекта или элементы одного и того же массива . [13] Пример:
int main ( void ) { int a = 0 ; int b = 0 ; return & a < & b ; /* неопределенное поведение */ }
Достижение конца функции, возвращающей значение (кроме main()
), без оператора return приводит к неопределенному поведению, если значение вызова функции используется вызывающей стороной: [14]
int f () { } /* неопределенное поведение, если используется значение вызова функции*/
Изменение объекта между двумя точками последовательности более одного раза приводит к неопределенному поведению. [15] Существуют значительные изменения в причинах неопределенного поведения в отношении точек последовательности, начиная с C++11. [16] Современные компиляторы могут выдавать предупреждения, когда сталкиваются с несколькими непоследовательными изменениями одного и того же объекта. [17] [18] Следующий пример вызовет неопределенное поведение как в C, так и в C++.
int f ( int i ) { return i ++ + i ++ ; /* неопределенное поведение: две непоследовательные модификации i */ }
При изменении объекта между двумя точками последовательности чтение значения объекта для любой другой цели, кроме определения значения, которое должно быть сохранено, также является неопределенным поведением. [19]
a [ i ] = i ++ ; // неопределенное поведение printf ( "%d %d \n " , ++ n , power ( 2 , n )); // также неопределенное поведение
В C/C++ побитовый сдвиг значения на количество бит, которое является либо отрицательным числом, либо больше или равно общему количеству бит в этом значении, приводит к неопределенному поведению. Самый безопасный способ (независимо от поставщика компилятора) — всегда сохранять количество бит для сдвига (правый операнд побитовых операторов и<<
) в диапазоне: [ ] (где — левый операнд).>>
0, sizeof value * CHAR_BIT - 1
value
int num = -1 ; unsigned int val = 1 << num ; // сдвиг на отрицательное число - неопределенное поведение num = 32 ; // или любое число больше 31 val = 1 << num ; // литерал '1' типизируется как 32-битное целое число — в этом случае сдвиг более чем на 31 бит является неопределенным поведением num = 64 ; // или любое число больше 63 unsigned long long val2 = 1ULL << num ; // литерал '1ULL' типизирован как 64-битное целое число - в этом случае сдвиг более чем на 63 бита является неопределенным поведением
Хотя неопределенное поведение никогда не присутствует в безопасном Rust , в небезопасном Rust можно вызвать неопределенное поведение многими способами. [20] Например, создание недопустимой ссылки (ссылки, которая не ссылается на допустимое значение) немедленно вызывает неопределенное поведение:
fn main () { // Следующая строка вызывает немедленное неопределенное поведение. let _null_reference : & i32 = unsafe { std :: mem :: zeroed () }; }
Использовать ссылку не обязательно; неопределенное поведение вызывается просто при создании такой ссылки.