Неопределенное поведение
В программировании компьютерном неопределенное поведение ( UB ) — это результат выполнения программы, поведение которой предписано быть непредсказуемым в спецификации языка , которой соответствует компьютерный код . Это отличается от неопределенного поведения , для которого спецификация языка не предписывает результат, и поведения, определяемого реализацией, которое зависит от документации другого компонента платформы ( например, ABI или документации переводчика ).
В сообществе программистов на языке неопределенное поведение можно с юмором назвать « носовыми демонами » после публикации на comp.std.c , в которой неопределенное поведение объясняется как разрешение компилятору делать все, что он пожелает, даже «заставлять демонов вылетать из вашего компьютера». нос". [1]
Обзор [ править ]
Некоторые языки программирования позволяют программе работать иначе или даже иметь поток управления, отличный от исходного кода , при условии, что она демонстрирует те же видимые пользователю побочные эффекты , если неопределенное поведение никогда не происходит во время выполнения программы . Неопределенное поведение — это название списка условий, которым программа не должна соответствовать.
В ранних версиях C основным преимуществом неопределенного поведения было создание производительных компиляторов для широкого спектра машин: конкретная конструкция могла быть сопоставлена с специфичной для машины функцией, и компилятору не приходилось генерировать дополнительный код для среды выполнения. адаптировать побочные эффекты в соответствии с семантикой, заданной языком. Исходный код программы был написан с предварительным знанием конкретного компилятора и платформ , которые он будет поддерживать.
Однако прогрессивная стандартизация платформ сделала это преимущество меньше, особенно в новых версиях C. Теперь случаи неопределенного поведения обычно представляют собой однозначные ошибки в коде, например, индексацию массива за его пределами. По определению, среда выполнения может предполагать, что неопределенное поведение никогда не происходит; поэтому некоторые недопустимые условия не нуждаются в проверке. Для компилятора это также означает, что различные преобразования программы становятся действительными или доказательства их корректности упрощаются; это позволяет проводить различные виды оптимизации, корректность которых зависит от предположения, что состояние программы никогда не удовлетворяет никакому такому условию. Компилятор также может удалить явные проверки, которые могли быть в исходном коде, не уведомляя программиста; например, обнаружение неопределенного поведения путем проверки того, произошло ли оно, по определению не гарантирует работу. Это затрудняет или делает невозможным программирование портативной отказоустойчивой опции (для некоторых конструкций возможны непереносимые решения).
Текущая разработка компилятора обычно оценивает и сравнивает производительность компилятора с тестами, разработанными на основе микрооптимизаций, даже на платформах, которые в основном используются на рынке настольных компьютеров и ноутбуков общего назначения (например, amd64). Таким образом, неопределенное поведение предоставляет широкие возможности для повышения производительности компилятора, поскольку исходный код для определенного оператора исходного кода может быть сопоставлен с чем угодно во время выполнения.
Для C и C++ в этих случаях компилятору разрешено выдавать диагностику во время компиляции, но это не обязательно: реализация будет считаться правильной, что бы она ни делала в таких случаях, аналогично терминам безразличия в цифровой логике. . Программист несет ответственность за написание кода, который никогда не вызывает неопределенное поведение, хотя реализации компилятора могут выдавать диагностику, когда это происходит. В настоящее время компиляторы имеют флаги, позволяющие такую диагностику, например: -fsanitize=undefined
включает «дезинфицирующее средство неопределенного поведения» ( UBSan ) в gcc 4.9 [2] и в лязге . Однако этот флаг не установлен по умолчанию, и его включение — выбор человека, создающего код.
При некоторых обстоятельствах могут быть определенные ограничения на неопределенное поведение. Например, набора команд в спецификациях ЦП поведение некоторых форм команд может оставаться неопределенным, но если ЦП поддерживает защиту памяти , тогда спецификация, вероятно, будет включать общее правило, гласящее, что ни одна доступная пользователю инструкция не может вызвать дыру в памяти. операционной системы безопасность ; поэтому реальному ЦП будет разрешено повредить пользовательские регистры в ответ на такую инструкцию, но ему не будет разрешено, например, переключиться в режим супервизора .
среды выполнения Платформа также может предоставлять некоторые ограничения или гарантии неопределенного поведения, если цепочка инструментов или среда выполнения явно документируют, что определенные конструкции, найденные в исходном коде, сопоставляются с конкретными четко определенными механизмами, доступными во время выполнения. Например, интерпретатор может документировать определенное поведение для некоторых операций, которые не определены в спецификации языка, в то время как другие интерпретаторы или компиляторы для того же языка не могут этого сделать. Компилятор способами, которые зависят создает исполняемый код для определенного ABI , заполняя семантический пробел от версии компилятора: документация для этой версии компилятора и спецификация ABI могут предоставлять ограничения на неопределенное поведение. Использование этих деталей реализации делает программное обеспечение непереносимым , но переносимость может не вызывать беспокойства, если программное обеспечение не предполагается использовать за пределами определенной среды выполнения.
Неопределенное поведение может привести к сбою программы или даже к сбоям, которые труднее обнаружить и заставить программу выглядеть так, как будто она работает нормально, например, к молчаливой потере данных и выдаче неверных результатов.
Преимущества [ править ]
Документирование операции как неопределенного поведения позволяет компиляторам предполагать, что эта операция никогда не произойдет в соответствующей программе. Это дает компилятору больше информации о коде, и эта информация может привести к увеличению возможностей оптимизации.
Пример для языка C:
int foo(unsigned char x)
{
int value = 2147483600; /* assuming 32-bit int and 8-bit 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;
}
Если бы компилятор был вынужден предположить, что переполнение знакового целого числа имеет циклическое поведение, то приведенное выше преобразование было бы недопустимым.
Такие оптимизации становится трудно обнаружить людям, когда код становится более сложным и другие оптимизации, такие как встраивание имеют место . Например, другая функция может вызвать указанную выше функцию:
void run_tasks(unsigned char *ptrx) {
int z;
z = foo(*ptrx);
while (*ptrx > 60) {
run_one_task(ptrx, z);
}
}
Компилятор волен оптимизировать while
-цикл здесь, применив анализ диапазона значений : проверив foo()
, он знает, что начальное значение, на которое указывает ptrx
не может превышать 47 (так как любое большее число вызовет неопределенное поведение в foo()
); поэтому первоначальная проверка *ptrx > 60
всегда будет ложным в соответствующей программе. Идем дальше, поскольку результат z
теперь никогда не используется и foo()
не имеет побочных эффектов, компилятор может оптимизировать run_tasks()
быть пустой функцией, которая возвращается немедленно. Исчезновение while
-loop может быть особенно неожиданным, если 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 и C++ [ править ]
Основные формы неопределенного поведения в C можно в общих чертах классифицировать как: [9] нарушения безопасности пространственной памяти, нарушения безопасности временной памяти , переполнение целых чисел, нарушения строгого псевдонимов, нарушения выравнивания, неупорядоченные модификации, гонки данных и циклы, которые не выполняют ввод-вывод и не завершаются.
В C использование любой автоматической переменной до ее инициализации приводит к неопределенному поведению, как и целочисленное деление на ноль , переполнение целого числа со знаком, индексация массива за пределами его определенных границ (см. Переполнение буфера ) или нулевого указателя разыменование . В общем, любой экземпляр неопределенного поведения оставляет абстрактную исполняющую машину в неизвестном состоянии и приводит к тому, что поведение всей программы становится неопределенным.
Попытка изменить строковый литерал приводит к неопределенному поведению: [10]
char *p = "wikipedia"; // valid C, deprecated in C++98/C++03, ill-formed as of C++11
p[0] = 'W'; // undefined behavior
Целочисленное деление на ноль приводит к неопределенному поведению: [11]
int x = 1;
return x / 0; // undefined behavior
Некоторые операции с указателями могут привести к неопределенному поведению: [12]
int arr[4] = {0, 1, 2, 3};
int *p = arr + 5; // undefined behavior for indexing out of bounds
p = NULL;
int a = *p; // undefined behavior for dereferencing a null pointer
В C и C++ реляционное сравнение указателей на объекты (для сравнения «меньше» или «больше») строго определено только в том случае, если указатели указывают на члены одного и того же объекта или элементы одного и того же массива . [13] Пример:
int main(void)
{
int a = 0;
int b = 0;
return &a < &b; /* undefined behavior */
}
Достижение конца функции, возвращающей значение (кроме main()
) без оператора return приводит к неопределенному поведению, если значение вызова функции используется вызывающей стороной: [14]
int f()
{
} /* undefined behavior if the value of the function call is used*/
Изменение объекта между двумя точками последовательности более одного раза приводит к неопределенному поведению. [15] Начиная с C++11, произошли значительные изменения в причинах неопределенного поведения в отношении точек последовательности. [16] Современные компиляторы могут выдавать предупреждения, когда сталкиваются с несколькими неупорядоченными модификациями одного и того же объекта. [17] [18] Следующий пример вызовет неопределенное поведение как в C, так и в C++.
int f(int i) {
return i++ + i++; /* undefined behavior: two unsequenced modifications to i */
}
При изменении объекта между двумя точками последовательности чтение значения объекта для любой другой цели, кроме определения сохраняемого значения, также является неопределенным поведением. [19]
a[i] = i++; // undefined behavior
printf("%d %d\n", ++n, power(2, n)); // also undefined behavior
В C/C++ побитовый сдвиг значения на число битов, которое либо является отрицательным числом, либо больше или равно общему количеству битов в этом значении, приводит к неопределенному поведению. Самый безопасный способ (независимо от производителя компилятора) — всегда сохранять количество смещаемых битов (правый операнд <<
и >>
побитовые операторы ) в диапазоне: [ 0, sizeof value * CHAR_BIT - 1
] (где value
левый операнд).
int num = -1;
unsigned int val = 1 << num; // shifting by a negative number - undefined behavior
num = 32; // or whatever number greater than 31
val = 1 << num; // the literal '1' is typed as a 32-bit integer - in this case shifting by more than 31 bits is undefined behavior
num = 64; // or whatever number greater than 63
unsigned long long val2 = 1ULL << num; // the literal '1ULL' is typed as a 64-bit integer - in this case shifting by more than 63 bits is undefined behavior
Примеры в Rust [ править ]
Хотя неопределенное поведение никогда не присутствует в безопасном Rust , в небезопасном Rust можно вызвать неопределенное поведение разными способами. [20] Например, создание недопустимой ссылки (ссылки, которая не ссылается на допустимое значение) вызывает немедленное неопределенное поведение:
fn main() {
// The following line invokes immediate undefined behaviour.
let _null_reference: &i32 = unsafe { std::mem::zeroed() };
}
Ссылку использовать не обязательно; неопределенное поведение вызывается просто при создании такой ссылки.
См. также [ править ]
Ссылки [ править ]
- ^ «носовые демоны» . Файл жаргона . Проверено 12 июня 2014 г.
- ^ Дезинфицирующее средство для неопределенного поведения GCC - ubsan
- ^ «Немного информации о компиляторах, использующих знаковое переполнение» .
- ^ ISO/IEC 9899:2011 §J.2.
- ^ Джон Регер (19 октября 2017 г.). «Неопределенное поведение в 2017 году, cppcon 2017» . Ютуб .
- ^ «Примечание об уязвимости VU#162289 — gcc молча отменяет некоторые циклические проверки» . База данных заметок об уязвимостях . СЕРТ. 4 апреля 2008 г. Архивировано из оригинала 9 апреля 2008 г.
- ^ Джонатан Корбет (16 апреля 2008 г.). «GCC и переполнение указателя» . Еженедельные новости Linux .
- ^ «Примечание об уязвимости VU#162289 — компиляторы C могут молча отбрасывать некоторые циклические проверки» . База данных заметок об уязвимостях . СЕРТ. 8 октября 2008 г. [4 апреля 2008 г.].
- ^ Паскаль Куок и Джон Регер (4 июля 2017 г.). «Неопределенное поведение в 2017 году, встроенное в академический блог» .
- ^ ИСО / МЭК (2003). ISO/IEC 14882:2003(E): Языки программирования – C++ §2.13.4 Строковые литералы [lex.string] параграф. 2
- ^ ИСО / МЭК (2003). ISO/IEC 14882:2003(E): Языки программирования – C++ §5.6 Мультипликативные операторы [expr.mul], параграф. 4
- ^ ИСО / МЭК (2003). ISO/IEC 14882:2003(E): Языки программирования – C++ §5.7 Аддитивные операторы [expr.add], параграф. 5
- ^ ИСО / МЭК (2003). ISO/IEC 14882:2003(E): Языки программирования – C++ §5.9 Операторы отношения [expr.rel], параграф. 2
- ^ ИСО / МЭК (2007). ISO/IEC 9899:2007(E): Языки программирования – C §6.9 Внешние определения, параграф. 1
- ^ ANSI X3.159-1989 Язык программирования C , сноска 26
- ^ «Порядок оценки — cppreference.com» . ru.cppreference.com . Проверено 9 августа 2016 г.
- ^ «Параметры предупреждения (с использованием коллекции компиляторов GNU (GCC))» . GCC, Коллекция компиляторов GNU — Проект GNU — Фонд свободного программного обеспечения (FSF) . Проверено 9 июля 2021 г.
- ^ «Диагностические флаги в Clang» . Документация Clang 13 . Проверено 9 июля 2021 г.
- ^ ИСО / МЭК (1999). ISO/IEC 9899:1999(E): Языки программирования – C §6.5 Выражения, параграф. 2
- ^ «Поведение считается неопределенным» . Справочник по ржавчине . Проверено 28 ноября 2022 г.
Дальнейшее чтение [ править ]
- Питер ван дер Линден , эксперт по программированию на языке C. ISBN 0-13-177429-8
- UB Canaries (апрель 2015 г.), Джон Регер (Университет Юты, США)
- Неопределенное поведение в 2017 г. (июль 2017 г.) Паскаль Куок (TrustInSoft, Франция) и Джон Регер (Университет Юты, США)
Внешние ссылки [ править ]
- Исправленная версия стандарта C99 . См. раздел 6.10.6 для #pragma.