В статье исследуются возможные проявления неопределённого поведения, возникающего в c++ при завершении не-void функции без вызова return с подходящим значением. Статья носит больше научно-развлекательный характер, чем практический.
Кому не нравится весело скакать по граблям — проходим мимо, не задерживаемся.
Всем известно, что при разработке c++-кода следует не допускать неопределённого поведения.
Однако:
Попробуем конкретизировать возможные проявления неопределённого поведения, возникающего в одном довольно простом случае — в не-void функции отсутствует return.
Для этого рассмотрим код, генерируемый наиболее популярными компиляторами в разных режимах оптимизации.
Исследования под Linux будут проводиться с помощью Compiler Explorer. Исследования под Windows и macOs X — на непосредственно доступном мне железе.
Все сборки будут делаться для x86-x64.
Никаких мер для усиления либо подавления предупреждений/ошибок компиляторов предприниматься не будет.
Будет много дизассемблированного кода. Его оформление, к сожалению, разношёрстное, т.к. приходится использовать несколько разных инструментов (хорошо хоть удалось добиться везде синтаксиса Intel). К дизассемблированному коду я буду давать в меру подробные комментарии, которые, однако, не избавляют от необходимости знания регистров процессора и принципов работы стека.
C++11 final draft n3797, C++14 final draft N3936:
C++17 draft n4713
Что это значит на практике?
Если сигнатура функции предусматривает возвращаемое значение:
Фраза про функцию main не является новшеством c++17 — в предыдущих версиях Стандарта аналогичное исключение было описано в разделе 3.6.1 Main function.
В c++ нет ни одного типа с состоянием более простым, чем bool. Вот с него и начнём.
MSVC выдаёт на такой пример ошибку компиляции C4716, поэтому для MSVC код придётся слегка усложнить, предоставив хотя бы один корректный путь выполнения:
Компиляция:
Результаты выполнения:
Даже в этом простейшем примере четыре компилятора продемонстрировали как минимум три варианта проявления неопределённого поведения.
Идём разбираться, что же там эти компиляторы накомпилировали.
Последняя инструкция в функции bad() — ud2.
Описание инструкции из Intel 64 and IA-32 Architectures Software Developer’s Manual:
Если кратко — это специальная инструкция для генерации исключения.
Надо обернуть вызов bad() в блок try… catch !?
Как бы не так. Это не c++-исключение.
Можно ли отловить ud2 в рантайме?
Под Windows для этого следует использовать __try, под Linux и macOs X — обработчик сигнала SIGILL.
В результате оптимизации компилятор просто взял и выбросил как тело функции bad(), так и её вызов.
Пояснения (в обратном порядке, т.к. в данном случае цепочку проще разбирать с конца):
5. Вызывается оператор вывода в stream для bool (строка 14);
4. В регистр edi помещается адрес std::cout — это первый аргумент оператора вывода в stream (строка 13);
3. В регистр esi помещается содержимое регистра eax — это второй аргумент оператора вывода в stream (строка 12);
2. Обнуляются три старших байта eax, значение al при этом не меняется (строка 11);
1. Вызывается функция bad() (строка 10);
0. Функция bad() должна поместить возвращаемое значение в регистр al.
Вместо этого в строке 4 — nop (No Operation, пустышка).
В консоль выводится один байт мусора из регистра al. Программа завершается штатно.
Компилятор всё повыбрасывал в результате оптимизации.
Функция main():
Путь булевского аргумента оператора вывода в поток (на сей раз в прямом порядке):
1. В регистр edx помещается содержимое регистра al (строка 8);
2. Зануляются все биты регистра edx, кроме младшего (строка 9);
3. В регистр rdi помещается указатель на std::cout — это первый аргумент оператора вывода в stream (строка 10);
4. В регистр esi помещается содержимое регистра edx — это второй аргумент оператора вывода в stream (строка 11);
5. Вызывается оператор вывода в stream для bool (строка 13);
Функция main ожидает получить результат выполнения функции bad() из регистра al.
Функция bad():
1. В регистр al помещается значение из следующего, ещё не выделенного, байта стека (строка 4);
2. Зануляются все биты регистра al, кроме младшего (строка 5);
В консоль выводится один бит мусора из нераспределённого стека. Так получилось, что при тестовом запуске там оказался ноль.
Программа завершается штатно.
Булевский аргумент оператора вывода в stream обнуляется (строка 5).
Вызов bad() выброшен при оптимизации.
Программа всегда выводит в консоль ноль и завершается штатно.
Видно, что функция bad() должна предоставить возвращаемое значение в регистре al.
Значение, возвращённое функцией bad(), помещается сначала на стек, а потом в регистр edx для вывода в stream.
В консоль выводится один байт мусора из регистра al (если чуть точнее — то младший байт результата rand()). Программа завершается штатно.
Компилятор принудительно заинлайнил вызов bad(). Функция main():
В stream выводится один байт мусора из оперативной памяти (из адреса rsp+30h).
Результаты рассмотрения листингов дизассемблера приведены в таблице:
Как оказалось, компиляторы продемонстрировали не 3, а целых 6 вариантов неопределённого поведения — просто до рассмотрения листингов дизассемблера мы не могли различить некоторые из них.
Попробуем немного порулить неопределённым поведением — повлиять на значение, возвращаемое функцией bad().
Это можно проделать только с теми компиляторами, которые выводят мусор.
Для этого надо подсовывать желаемые значения в те места, из которых компиляторы их будут брать.
Пустая функция bad() не модифицирует значение регистра al, как от неё требует вызывающий код. Таким образом, если мы разместим в al определённое значение до вызова bad(), то ожидаем увидеть именно это значение в качестве результата выполнения bad().
Очевидно, что это можно сделать с помощью вызова любой другой функции, возвращающей bool. Но также это можно сделать с помощью функции, возвращающей, например, unsinged char.
Вывод в консоль:
В примере для MSVC функция bad() возвращает младший байт результата rand().
Без модификации функции bad() внешний код может повлиять на возвращаемое ею значение, изменяя результат rand().
Вывод в консоль:
Чтобы повлиять не значение, «возвращаемое» функцией bad(), достаточно создать одну стековую переменную. Чтоб запись в неё не была выброшена при оптимизации, следует пометить её как volatile.
Вывод в консоль:
Надо перед вызовом bad() вписать определённое значение в ту ячейку памяти, которая будет на единицу младше вершины стека в момент вызова bad().
Вывод в консоль:
Вроде получилось: удаётся менять выдачу функции bad(), и при этом учитывается только младший бит.
Пример позволил убедиться в корректности трактовки листингов дизассемблера.
Ну подууууумаешь, в консоль выведется «41» вместо «1»… Разве это опасно?
Проверять будем на двух компиляторах, предоставивших целый байт мусора.
Вывод в консоль:
Неопределённое поведение привело к возникновению булевской переменной, которая ломает как минимум:
Вывод в консоль:
Работа с испорченной булевской переменной не изменилась при включении оптимизации.
Вывод в консоль:
По сравнению с MSVC, в gcc добавилась ещё и некорректная работа оператора not.
Нарушение работы базовых операций с булевскими значениями может иметь серьёзные последствия для высокоуровневой логики.
Почему так произошло?
Потому что некоторые операции с булевскими переменными реализованы в предположении, что true — это строго единица.
В дизассемблере этот вопрос рассматривать не будем — статья и так получилась объёмной.
В очередной раз уточним таблицу с поведением компиляторов:
Четыре компилятора дали 7 различных проявлений неопределённого поведения.
Возьмём пример чуть посложнее:
Структура Test требует для конструирования один параметр типа int. Из её конструктора и деструктора производится вывод диагностических сообщений. Функция bad(int) имеет два корректных пути выполнения, ни один из которых не будет реализован при единственном вызове.
На этот раз — сначала таблица, потом разбор дизассемблера по непонятным пунктам.
Опять мы видим множество вариантов: кроме уже известного ud2 есть ещё как минимум 4 разных поведения.
Весьма интересно обращение компиляторов с конструктором:
В коде производится только одно сравнение (строка 14), и присутствует только один условный переход (строка 15). Компилятор проигнорировал второе сравнение и второй условный переход.
Это наводит на подозрение, что неопределённое поведение началось раньше, чем предписывает Стандарт.
Но проверка условия второго if не содержит побочных эффектов, и логика компилятора сработала следующим образом:
Посмотрим, что произойдёт, если вторая проверка будет содержать условие с побочными эффектами:
Компилятор честно воспроизвёл все положенные побочные эффекты, вызвав rand() (строка 16), чем развеял сомнения о неподобающе раннем начале неопределённого поведения.
Опция /RTCs включает stack frame run-time error checking. Эта опция доступна только в debug-сборке. Рассмотрим дизассемблированный код участка main():
Перед вызовом bad(int) (строка 4) производится подготовка аргументов — в регистр edx копируется значение переменной rnd (строка 2), и в регистр rcx загружается эффективный адрес какой-то локальной переменной, расположенной по адресу rsp+28h (строка 3).
Предположительно, rsp+28 — адрес временной переменной, хранящей результат вызова bad(int).
Это предположение подтверждается строками 19 и 20 — эффективный адрес этой же переменной загружается в rcx, после чего вызывается деструктор.
Однако в интервале строк 4 — 18 к этой переменной нет обращения, несмотря на вывод в stream значения её поля данных.
Как мы видели из прошлых листингов MSVC, аргумент для оператора вывода в поток следует ожидать в регистре rdx. В регистр rdx попадает результат разыменования адреса, находящегося в rax (строка 9).
Таким образом, вызывающий код ожидает от bad(int):
Переходим к рассмотрению листинга bad(int):
Таким образом, rsp+8 в строке 4 и rsp+40h в остальной части кода — одно и то же значение.
Код довольно запутанный, т.к. в нём не применяется rbp.
В сообщении Access Violation есть целых две случайности:
Судя по всему, опция /RTCs включила затирание стека определёнными ненулевыми значениями, а сообщение Access Violation — лишь случайный побочный эффект.
Посмотрим, чем отличается код со включённой опцией /RTCs от кода без неё.
Код участков main() отличается только адресами локальных переменных на стеке.
(для наглядности я разместил рядом два варианта функции bad(int) — с /RTCs и без)
Без /RTCs исчезла инструкция rep stos и подготовка аргументов для неё в начале функции.
Снова попробуем поуправлять неопределённым поведением. На этот раз только для одного компилятора.
С опцией /RTCs компилятор вставляет в начало функции bad(int) код, заполняющий младшую половину rax фиксированным значеним, что может приводить к Access violation.
Чтобы изменить это поведение, достаточно заполнить rax каким-либо корректным адресом.
Этого можно добиться очень простой модификацией: добавить в тело bad(int) вывод чего-нибудь в std::cout.
operator<< возвращает ссылку на stream, что реализуется как размещение адреса std::cout в rax. Адрес корректный, его можно разыменовывать. Access violation предотвращён.
На простейших примерах нам удалось:
Все компиляторы продемонстрировали чёткое следование Стандарту — ни в одном примере неопределённое поведение не началось раньше положенного. Но и в фантазии разработчикам компиляторов не откажешь.
Зачастую проявление зависит от тонких нюансов: стоит добавить или убрать одну, казалось бы, не относящуюся к делу строку кода — и поведение программы существенно меняется.
Очевидно, что проще не писать такой код, чем потом разгадывать ребусы.
Кому не нравится весело скакать по граблям — проходим мимо, не задерживаемся.
Введение
Всем известно, что при разработке c++-кода следует не допускать неопределённого поведения.
Однако:
- неопределённое поведение может казаться не достаточно опасным из-за абстрактности возможных последствий;
- не всегда понятно, где грань.
Попробуем конкретизировать возможные проявления неопределённого поведения, возникающего в одном довольно простом случае — в не-void функции отсутствует return.
Для этого рассмотрим код, генерируемый наиболее популярными компиляторами в разных режимах оптимизации.
Исследования под Linux будут проводиться с помощью Compiler Explorer. Исследования под Windows и macOs X — на непосредственно доступном мне железе.
Все сборки будут делаться для x86-x64.
Никаких мер для усиления либо подавления предупреждений/ошибок компиляторов предприниматься не будет.
Будет много дизассемблированного кода. Его оформление, к сожалению, разношёрстное, т.к. приходится использовать несколько разных инструментов (хорошо хоть удалось добиться везде синтаксиса Intel). К дизассемблированному коду я буду давать в меру подробные комментарии, которые, однако, не избавляют от необходимости знания регистров процессора и принципов работы стека.
Читаем Стандарт
C++11 final draft n3797, C++14 final draft N3936:
6.6.3 The return statement
…
Flowing off the end of a function is equivalent to a return with no value; this results in undefined
behavior in a value-returning function.
…
Достижение конца функции эквивалентно выражению return без возвращаемого значения; для функции, у которой возвращаемое значение предусмотрено, это приводит к неопределённому поведению.
C++17 draft n4713
9.6.3 The return statement
…
Flowing off the end of a constructor, a destructor, or a function with a cv void return type is equivalent to a return with no operand. Otherwise, flowing off the end of a function other than main (6.8.3.1) results in undefined behavior.
…
Достижение конца конструктора, деструктора или функции с возвращаемым значением void (возможно, с квалификаторами const и volatile) эквивалентно выражению return без возвращаемого значения. Для всех других функций это приводит к неопределённому поведению (кроме функции main).
Что это значит на практике?
Если сигнатура функции предусматривает возвращаемое значение:
- её выполнение должно завершаться выражением return с экземпляром подходящего типа;
- иначе — неопределённое поведение;
- неопределённое поведение начинается не с момента вызова такой функции и не с момента использования возвращённого значение, а с момента ненадлежащего завершения такой функции;
- если функция содержит как корректные, так и некорректные пути выполнения — неопределённое поведение будет возникать только на некорректных путях;
- рассматриваемое неопределённое поведение не затрагивает выполнение инструкций, содержащихся в теле функции.
Фраза про функцию main не является новшеством c++17 — в предыдущих версиях Стандарта аналогичное исключение было описано в разделе 3.6.1 Main function.
Пример 1 — bool
В c++ нет ни одного типа с состоянием более простым, чем bool. Вот с него и начнём.
#include <iostream>
bool bad() {};
int main()
{
std::cout << bad();
return 0;
}
MSVC выдаёт на такой пример ошибку компиляции C4716, поэтому для MSVC код придётся слегка усложнить, предоставив хотя бы один корректный путь выполнения:
#include <iostream>
#include <stdlib.h>
bool bad()
{
if (rand() == 0) {
return true;
}
}
int main()
{
std::cout << bad();
return 0;
}
Компиляция:
Платформа | Компилятор | Результат компиляции |
---|---|---|
Linux | x86-x64 Clang 10.0.0 | warning: non-void function does not return a value [-Wreturn-type] |
Linux | x86-x64 gcc 9.3 | warning: no return statement in function returning non-void [-Wreturn-type] |
macOs X | Apple clang version 11.0.0 | warning: control reaches end of non-void function [-Wreturn-type] |
Windows | MSVC 2019 16.5.4 | Оригинальный пример — error C4716, усложнённый — warning C4715: not all control paths return a value |
Результаты выполнения:
Оптимизация | Program return | Console output |
Linux x86-x64 Clang 10.0.0 | ||
-O0 | 255 | No output |
-O1, -O2 | 0 | No output |
Linux x86-x64 gcc 9.3 | ||
-O0 | 0 | 89 |
-O1, -O2, -O3 | 0 | No output |
macOs X Apple clang version 11.0.0 | ||
-O0, -O1, -O2 | 0 | 0 |
Windows MSVC 2019 16.5.4, оригинальный пример | ||
/Od, /O1, /O2 | No build | No build |
Windows MSVC 2019 16.5.4, усложнённый пример | ||
/Od | 0 | 41 |
/O1, /O2 | 0 | 1 |
Даже в этом простейшем примере четыре компилятора продемонстрировали как минимум три варианта проявления неопределённого поведения.
Идём разбираться, что же там эти компиляторы накомпилировали.
Linux x86-x64 Clang 10.0.0, -O0
Последняя инструкция в функции bad() — ud2.
Описание инструкции из Intel 64 and IA-32 Architectures Software Developer’s Manual:
UD2—Undefined Instruction
Generates an invalid opcode exception. This instruction is provided for software testing to explicitly generate an invalid opcode exception. The opcode for this instruction is reserved for this purpose.
Other than raising the invalid opcode exception, this instruction has no effect on processor state or memory.
Even though it is the execution of the UD2 instruction that causes the invalid opcode exception, the instruction pointer saved by delivery of the exception references the UD2 instruction (and not the following instruction).
This instruction’s operation is the same in non-64-bit modes and 64-bit mode.
Если кратко — это специальная инструкция для генерации исключения.
Надо обернуть вызов bad() в блок try… catch !?
Как бы не так. Это не c++-исключение.
Можно ли отловить ud2 в рантайме?
Под Windows для этого следует использовать __try, под Linux и macOs X — обработчик сигнала SIGILL.
Linux x86-x64 Clang 10.0.0, -O1, -O2
В результате оптимизации компилятор просто взял и выбросил как тело функции bad(), так и её вызов.
Linux x86-x64 gcc 9.3, -O0
Пояснения (в обратном порядке, т.к. в данном случае цепочку проще разбирать с конца):
5. Вызывается оператор вывода в stream для bool (строка 14);
4. В регистр edi помещается адрес std::cout — это первый аргумент оператора вывода в stream (строка 13);
3. В регистр esi помещается содержимое регистра eax — это второй аргумент оператора вывода в stream (строка 12);
2. Обнуляются три старших байта eax, значение al при этом не меняется (строка 11);
1. Вызывается функция bad() (строка 10);
0. Функция bad() должна поместить возвращаемое значение в регистр al.
Вместо этого в строке 4 — nop (No Operation, пустышка).
В консоль выводится один байт мусора из регистра al. Программа завершается штатно.
Linux x86-x64 gcc 9.3, -O1, -O2, -O3
Компилятор всё повыбрасывал в результате оптимизации.
macOs X Apple clang version 11.0.0, -O0
Функция main():
Путь булевского аргумента оператора вывода в поток (на сей раз в прямом порядке):
1. В регистр edx помещается содержимое регистра al (строка 8);
2. Зануляются все биты регистра edx, кроме младшего (строка 9);
3. В регистр rdi помещается указатель на std::cout — это первый аргумент оператора вывода в stream (строка 10);
4. В регистр esi помещается содержимое регистра edx — это второй аргумент оператора вывода в stream (строка 11);
5. Вызывается оператор вывода в stream для bool (строка 13);
Функция main ожидает получить результат выполнения функции bad() из регистра al.
Функция bad():
1. В регистр al помещается значение из следующего, ещё не выделенного, байта стека (строка 4);
2. Зануляются все биты регистра al, кроме младшего (строка 5);
В консоль выводится один бит мусора из нераспределённого стека. Так получилось, что при тестовом запуске там оказался ноль.
Программа завершается штатно.
macOs X Apple clang version 11.0.0, -O1, -O2
Булевский аргумент оператора вывода в stream обнуляется (строка 5).
Вызов bad() выброшен при оптимизации.
Программа всегда выводит в консоль ноль и завершается штатно.
Windows MSVC 2019 16.5.4, усложнённый пример, /Od
Видно, что функция bad() должна предоставить возвращаемое значение в регистре al.
Значение, возвращённое функцией bad(), помещается сначала на стек, а потом в регистр edx для вывода в stream.
В консоль выводится один байт мусора из регистра al (если чуть точнее — то младший байт результата rand()). Программа завершается штатно.
Windows MSVC 2019 16.5.4, усложнённый пример, /O1, /O2
Компилятор принудительно заинлайнил вызов bad(). Функция main():
- копирует в ebx один байт из памяти, находящейся по адресу [rsp+30h];
- в случае, если rand() вернул ноль, копирует единицу из ecx в ebx (строка 11);
- копирует это же значение в dl (точнее, его младший байт) (строка 13);
- вызывает функцию вывода в stream, осуществляющую вывод значения dl (строка 14).
В stream выводится один байт мусора из оперативной памяти (из адреса rsp+30h).
Вывод по примеру 1
Результаты рассмотрения листингов дизассемблера приведены в таблице:
Оптимизация | Program return | Console output | Причина |
Linux x86-x64 Clang 10.0.0 | |||
-O0 | 255 | No output | ud2 |
-O1, -O2 | 0 | No output | Вывод в консоль и вызов функции bad() выброшены в результате оптимизации |
Linux x86-x64 gcc 9.3 | |||
-O0 | 0 | 89 | Один байт мусора из регистра al |
-O1, -O2, -O3 | 0 | No output | Вывод в консоль и вызов функции bad() выброшены в результате оптимизации |
macOs X Apple clang version 11.0.0 | |||
-O0 | 0 | 0 | Один бит мусора из оперативной памяти |
-O1, -O2 | 0 | 0 | Вызов функции bad() заменён нулём |
Windows MSVC 2019 16.5.4, оригинальный пример | |||
/Od, /O1, /O2 | No build | No build | No build |
Windows MSVC 2019 16.5.4, усложнённый пример | |||
/Od | 0 | 41 | Один байт мусора из регистра al |
/O1, /O2 | 0 | 1 | Один байт мусора из оперативной памяти |
Как оказалось, компиляторы продемонстрировали не 3, а целых 6 вариантов неопределённого поведения — просто до рассмотрения листингов дизассемблера мы не могли различить некоторые из них.
Пример 1a — управление неопределённым поведением
Попробуем немного порулить неопределённым поведением — повлиять на значение, возвращаемое функцией bad().
Это можно проделать только с теми компиляторами, которые выводят мусор.
Для этого надо подсовывать желаемые значения в те места, из которых компиляторы их будут брать.
Linux x86-x64 gcc 9.3, -O0
Пустая функция bad() не модифицирует значение регистра al, как от неё требует вызывающий код. Таким образом, если мы разместим в al определённое значение до вызова bad(), то ожидаем увидеть именно это значение в качестве результата выполнения bad().
Очевидно, что это можно сделать с помощью вызова любой другой функции, возвращающей bool. Но также это можно сделать с помощью функции, возвращающей, например, unsinged char.
Полный код примера
#include <iostream>
bool bad() {}
bool goodTrue()
{
return rand();
}
bool goodFalse()
{
return !goodTrue();
}
unsigned char goodChar(unsigned char ch)
{
return ch;
}
int main()
{
goodTrue();
std::cout << bad() << std::endl;
goodChar(85);
std::cout << bad() << std::endl;
goodFalse();
std::cout << bad() << std::endl;
goodChar(240);
std::cout << bad() << std::endl;
return 0;
}
Вывод в консоль:
1
85
0
240
Windows MSVC 2019 16.5.4, /Od
В примере для MSVC функция bad() возвращает младший байт результата rand().
Без модификации функции bad() внешний код может повлиять на возвращаемое ею значение, изменяя результат rand().
Полный код примера
#include <iostream>
#include <stdlib.h>
void control(unsigned char value)
{
uint32_t count = 0;
srand(0);
while ((rand() & 0xff) != value) {
++count;
}
srand(0);
for (uint32_t i = 0; i < count; ++i) {
rand();
}
}
bool bad()
{
if (rand() == 0) {
return true;
}
}
int main()
{
control(1);
std::cout << bad() << std::endl;
control(85);
std::cout << bad() << std::endl;
control(0);
std::cout << bad() << std::endl;
control(240);
std::cout << bad() << std::endl;
return 0;
}
Вывод в консоль:
1
85
0
240
Windows MSVC 2019 16.5.4, /O1, /O2
Чтобы повлиять не значение, «возвращаемое» функцией bad(), достаточно создать одну стековую переменную. Чтоб запись в неё не была выброшена при оптимизации, следует пометить её как volatile.
Полный код примера
#include <iostream>
#include <stdlib.h>
bool bad()
{
if (rand() == 0) {
return true;
}
}
int main()
{
volatile unsigned char ch = 1;
std::cout << bad() << std::endl;
ch = 85;
std::cout << bad() << std::endl;
ch = 0;
std::cout << bad() << std::endl;
ch = 240;
std::cout << bad() << std::endl;
return 0;
}
Вывод в консоль:
1
85
0
240
macOs X Apple clang version 11.0.0, -O0
Надо перед вызовом bad() вписать определённое значение в ту ячейку памяти, которая будет на единицу младше вершины стека в момент вызова bad().
Полный код примера
Пример предназначен для компиляции с опцией -O0, так что не стоит беспокоиться о сохранности переменной memory. Она не будет выброшена при оптимизации даже несмотря на то, что нигде не используется.
При этом переменная memory должна быть не просто единичным значением, а массивом — иначе компилятор располагает её в регистр процессора, а не на стек, как нам надо.
Пример не является универсальным, т.к. вообще компиляторы могут выделять на стеке больше памяти, чем необходимо для пользовательских переменных — тогда функция putToStack в текущем виде будет промахиваться.
#include <iostream>
bool bad() {}
void putToStack(uint8_t value)
{
uint8_t memory[1]{value};
}
int main()
{
putToStack(20);
std::cout << bad() << std::endl;
putToStack(55);
std::cout << bad() << std::endl;
putToStack(0xfe);
std::cout << bad() << std::endl;
putToStack(11);
std::cout << bad() << std::endl;
return 0;
}
Пример предназначен для компиляции с опцией -O0, так что не стоит беспокоиться о сохранности переменной memory. Она не будет выброшена при оптимизации даже несмотря на то, что нигде не используется.
При этом переменная memory должна быть не просто единичным значением, а массивом — иначе компилятор располагает её в регистр процессора, а не на стек, как нам надо.
Пример не является универсальным, т.к. вообще компиляторы могут выделять на стеке больше памяти, чем необходимо для пользовательских переменных — тогда функция putToStack в текущем виде будет промахиваться.
Вывод в консоль:
0
1
0
1
Вроде получилось: удаётся менять выдачу функции bad(), и при этом учитывается только младший бит.
Вывод по примеру 1a
Пример позволил убедиться в корректности трактовки листингов дизассемблера.
Пример 1b — сломанный bool
Ну подууууумаешь, в консоль выведется «41» вместо «1»… Разве это опасно?
Проверять будем на двух компиляторах, предоставивших целый байт мусора.
Windows MSVC 2019 16.5.4, /Od
Полный код примера
#include <iostream>
#include <stdlib.h>
#include <set>
#include <unordered_set>
bool bad()
{
if (rand() == 0) {
return true;
}
}
int main()
{
bool badBool1 = bad();
bool badBool2 = bad();
std::cout << "badBool1: " << badBool1 << std::endl;
std::cout << "badBool2: " << badBool2 << std::endl;
if (badBool1) {
std::cout << "if (badBool1): true" << std::endl;
} else {
std::cout << "if (badBool1): false" << std::endl;
}
if (!badBool1) {
std::cout << "if (!badBool1): true" << std::endl;
} else {
std::cout << "if (!badBool1): false" << std::endl;
}
std::cout << "(badBool1 == true || badBool1 == false || badBool1 == badBool2): "
<< std::boolalpha << (badBool1 == true || badBool1 == false || badBool1 == badBool2)
<< std::endl;
std::cout << "std::set<bool>{badBool1, badBool2, true, false}.size(): "
<< std::set<bool>{badBool1, badBool2, true, false}.size()
<< std::endl;
std::cout << "std::unordered_set<bool>{badBool1, badBool2, true, false}.size(): "
<< std::unordered_set<bool>{badBool1, badBool2, true, false}.size()
<< std::endl;
return 0;
}
Вывод в консоль:
badBool1: 41
badBool2: 35
if (badBool1): true
if (!badBool1): false
(badBool1 == true || badBool1 == false || badBool1 == badBool2): false
std::set<bool>{badBool1, badBool2, true, false}.size(): 4
std::unordered_set<bool>{badBool1, badBool2, true, false}.size(): 4
Неопределённое поведение привело к возникновению булевской переменной, которая ломает как минимум:
- операторы сравнения булевских значений;
- хеш-функцию булевского значения.
Windows MSVC 2019 16.5.4, /O1, /O2
Полный код примера
#include <iostream>
#include <stdlib.h>
#include <set>
#include <unordered_set>
bool bad()
{
if (rand() == 0) {
return true;
}
}
int main()
{
volatile unsigned char ch = 213;
bool badBool1 = bad();
ch = 137;
bool badBool2 = bad();
std::cout << "badBool1: " << badBool1 << std::endl;
std::cout << "badBool2: " << badBool2 << std::endl;
if (badBool1) {
std::cout << "if (badBool1): true" << std::endl;
}
else {
std::cout << "if (badBool1): false" << std::endl;
}
if (!badBool1) {
std::cout << "if (!badBool1): true" << std::endl;
}
else {
std::cout << "if (!badBool1): false" << std::endl;
}
std::cout << "(badBool1 == true || badBool1 == false || badBool1 == badBool2): "
<< std::boolalpha << (badBool1 == true || badBool1 == false || badBool1 == badBool2)
<< std::endl;
std::cout << "std::set<bool>{badBool1, badBool2, true, false}.size(): "
<< std::set<bool>{badBool1, badBool2, true, false}.size()
<< std::endl;
std::cout << "std::unordered_set<bool>{badBool1, badBool2, true, false}.size(): "
<< std::unordered_set<bool>{badBool1, badBool2, true, false}.size()
<< std::endl;
return 0;
}
Вывод в консоль:
badBool1: 213
badBool2: 137
if (badBool1): true
if (!badBool1): false
(badBool1 == true || badBool1 == false || badBool1 == badBool2): false
std::set<bool>{badBool1, badBool2, true, false}.size(): 4
std::unordered_set<bool>{badBool1, badBool2, true, false}.size(): 4
Работа с испорченной булевской переменной не изменилась при включении оптимизации.
Linux x86-x64 gcc 9.3, -O0
Полный код примера
#include <iostream>
#include <stdlib.h>
#include <set>
#include <unordered_set>
bool bad()
{
}
unsigned char goodChar(unsigned char ch)
{
return ch;
}
int main()
{
goodChar(213);
bool badBool1 = bad();
goodChar(137);
bool badBool2 = bad();
std::cout << "badBool1: " << badBool1 << std::endl;
std::cout << "badBool2: " << badBool2 << std::endl;
if (badBool1) {
std::cout << "if (badBool1): true" << std::endl;
}
else {
std::cout << "if (badBool1): false" << std::endl;
}
if (!badBool1) {
std::cout << "if (!badBool1): true" << std::endl;
}
else {
std::cout << "if (!badBool1): false" << std::endl;
}
std::cout << "(badBool1 == true || badBool1 == false || badBool1 == badBool2): "
<< std::boolalpha << (badBool1 == true || badBool1 == false || badBool1 == badBool2)
<< std::endl;
std::cout << "std::set<bool>{badBool1, badBool2, true, false}.size(): "
<< std::set<bool>{badBool1, badBool2, true, false}.size()
<< std::endl;
std::cout << "std::unordered_set<bool>{badBool1, badBool2, true, false}.size(): "
<< std::unordered_set<bool>{badBool1, badBool2, true, false}.size()
<< std::endl;
return 0;
}
Вывод в консоль:
badBool1: 213
badBool2: 137
if (badBool1): true
if (!badBool1): true
(badBool1 == true || badBool1 == false || badBool1 == badBool2): false
std::set<bool>{badBool1, badBool2, true, false}.size(): 4
std::unordered_set<bool>{badBool1, badBool2, true, false}.size(): 4
По сравнению с MSVC, в gcc добавилась ещё и некорректная работа оператора not.
Вывод по примеру 1b
Нарушение работы базовых операций с булевскими значениями может иметь серьёзные последствия для высокоуровневой логики.
Почему так произошло?
Потому что некоторые операции с булевскими переменными реализованы в предположении, что true — это строго единица.
В дизассемблере этот вопрос рассматривать не будем — статья и так получилась объёмной.
В очередной раз уточним таблицу с поведением компиляторов:
Оптимизация | Program return | Console output | Причина | Последствия использования результата bad() |
Linux x86-x64 Clang 10.0.0 | ||||
-O0 | 255 | No output | ud2 | |
-O1, -O2 | 0 | No output | Вывод в консоль и вызов функции bad() выброшены в результате оптимизации | |
Linux x86-x64 gcc 9.3 | ||||
-O0 | 0 | 89 | Один байт мусора из регистра al | Нарушение работы: not; ==; !=; <; >; <=; >=; std::hash. |
-O1, -O2, -O3 | 0 | No output | Вывод в консоль и вызов функции bad() выброшены в результате оптимизации | |
macOs X Apple clang version 11.0.0 | ||||
-O0 | 0 | 0 | Один бит мусора из оперативной памяти | |
-O1, -O2 | 0 | 0 | Вызов функции bad() заменён нулём | |
Windows MSVC 2019 16.5.4, оригинальный пример | ||||
/Od, /O1, /O2 | No build | No build | No build | |
Windows MSVC 2019 16.5.4, усложнённый пример | ||||
/Od | 0 | 41 | Один байт мусора из регистра al | Нарушение работы: ==; !=; <; >; <=; >=; std::hash. |
/O1, /O2 | 0 | 1 | Один байт мусора из оперативной памяти | Нарушение работы: ==; !=; <; >; <=; >=; std::hash. |
Четыре компилятора дали 7 различных проявлений неопределённого поведения.
Пример 2 — struct
Возьмём пример чуть посложнее:
#include <iostream>
#include <stdlib.h>
struct Test
{
Test(uint64_t v)
: value(v)
{
std::cout << "Test::Test(" << v << ")" << std::endl;
}
~Test()
{
std::cout << "Test::~Test()" << std::endl;
}
uint64_t value;
};
Test bad(int v)
{
if (v == 0) {
return {42};
} else if (v == 1) {
return {142};
}
}
int main()
{
const auto rnd = rand();
std::cout << "rnd: " << rnd << std::endl;
std::cout << bad(rnd).value << std::endl;
return 0;
}
Структура Test требует для конструирования один параметр типа int. Из её конструктора и деструктора производится вывод диагностических сообщений. Функция bad(int) имеет два корректных пути выполнения, ни один из которых не будет реализован при единственном вызове.
На этот раз — сначала таблица, потом разбор дизассемблера по непонятным пунктам.
Оптимизация | Program return | Console output | Причина |
Linux x86-x64 Clang 10.0.0 | |||
-O0 | 255 | rnd: 1804289383 | ud2 |
-O1, -O2 | 0 | rnd: 1804289383 Test::Test(142) 142 Test::~Test() |
Проверка if (v == 1) не производится. Блок else if превратился в просто else. |
Linux x86-x64 gcc 9.3 | |||
-O0 | 0 | rnd: 1804289383 4198608 Test::~Test() |
nop вместо вызова конструктора на некорректном пути выполнения. value содержит мусор из стека. |
-O1, -O2, -O3 | 0 | rnd: 1804289383 Test::Test(142) 142 Test::~Test() |
Проверка if (v == 1) не производится. Блок else if превратился в просто else. |
macOs X Apple clang version 11.0.0 | |||
-O0 | The program has unexpectedly finished. | rnd: 16807 | ud2 |
-O1, -O2 | 0 | rnd: 16807 Test::Test(142) 142 Test::~Test() |
Проверка if (v == 1) не производится. Блок else if превратился в просто else. |
Windows MSVC 2019 16.5.4 | |||
/Od /RTCs | Access violation reading location 0x00000000CCCCCCCC | rnd: 41 | Побочный эффект MSVC stack frame run-time error checking |
/Od, /O1, /O2 | 0 | rnd: 41 8791061810776 Test::~Test() |
Мусор из ячейки памяти, адрес которой оказался в rax |
Опять мы видим множество вариантов: кроме уже известного ud2 есть ещё как минимум 4 разных поведения.
Весьма интересно обращение компиляторов с конструктором:
- в одних случаях выполнение продолжилось без вызова конструктора — в этом случае объект оказался в каком-то случайном состоянии;
- в других случаях произошёл вызов конструктора, не предусмотренный на пути выполнения, что довольно странно.
Linux x86-x64 Clang 10.0.0, -O1, -O2
В коде производится только одно сравнение (строка 14), и присутствует только один условный переход (строка 15). Компилятор проигнорировал второе сравнение и второй условный переход.
Это наводит на подозрение, что неопределённое поведение началось раньше, чем предписывает Стандарт.
Но проверка условия второго if не содержит побочных эффектов, и логика компилятора сработала следующим образом:
- если второе условие окажется верным — надо вызвать конструктор Test с аргументом 142;
- если второе условие окажется не верным — произойдёт выход из функции без возрата значения, что означает неопределённое поведение, при котором компилятор может сделать всё, что угодно. В том числе — вызвать тот же конструктор с тем же аргументом;
- проверка является лишней, вызов конструктора Test с аргументом 142 можно производить без проверки условия.
Посмотрим, что произойдёт, если вторая проверка будет содержать условие с побочными эффектами:
Test bad(int v)
{
if (v == 0) {
return {42};
} else if (v == rand()) {
return {142};
}
}
Полный код
#include <iostream>
#include <stdlib.h>
struct Test
{
Test(uint64_t v)
: value(v)
{
std::cout << "Test::Test(" << v << ")" << std::endl;
}
~Test()
{
std::cout << "Test::~Test()" << std::endl;
}
uint64_t value;
};
Test bad(int v)
{
if (v == 0) {
return {42};
} else if (v == rand()) {
return {142};
}
}
int main()
{
const auto rnd = rand();
std::cout << "rnd: " << rnd << std::endl;
std::cout << bad(rnd).value << std::endl;
return 0;
}
Компилятор честно воспроизвёл все положенные побочные эффекты, вызвав rand() (строка 16), чем развеял сомнения о неподобающе раннем начале неопределённого поведения.
Windows MSVC 2019 16.5.4, /Od /RTCs
Опция /RTCs включает stack frame run-time error checking. Эта опция доступна только в debug-сборке. Рассмотрим дизассемблированный код участка main():
Перед вызовом bad(int) (строка 4) производится подготовка аргументов — в регистр edx копируется значение переменной rnd (строка 2), и в регистр rcx загружается эффективный адрес какой-то локальной переменной, расположенной по адресу rsp+28h (строка 3).
Предположительно, rsp+28 — адрес временной переменной, хранящей результат вызова bad(int).
Это предположение подтверждается строками 19 и 20 — эффективный адрес этой же переменной загружается в rcx, после чего вызывается деструктор.
Однако в интервале строк 4 — 18 к этой переменной нет обращения, несмотря на вывод в stream значения её поля данных.
Как мы видели из прошлых листингов MSVC, аргумент для оператора вывода в поток следует ожидать в регистре rdx. В регистр rdx попадает результат разыменования адреса, находящегося в rax (строка 9).
Таким образом, вызывающий код ожидает от bad(int):
- заполнения переменной, адрес которой передан через регистр rcx (тут мы видим RVO в действии);
- возврат адреса этой переменной через регистр rax.
Переходим к рассмотрению листинга bad(int):
- в eax заносится значение 0xCCCCCCCC, которое мы видели в сообщении Access violation (строка 9) (обратите внимание — только 4 байта, в то время как в сообщении AccessViolation адрес состоит из 8 байт);
- вызывается команда rep stos, осуществляющая 0xC циклов записи содержимого eax в память начиная с адреса rdi (строка 10). Это 48 байтов — ровно столько, сколько выделено на стеке в строке 6;
- на корректных путях выполнения в rax заносится значение из rsp+40h (строки 23, 36);
- значение регистра rcx (через который main() передал адрес назначения) помещается на стек по адресу rsp+8 (строка 4);
- в стек впихивается rdi, что приводит к уменьшению rsp на 8 (строка 5);
- на стеке выделяется 30h байт путём уменьшению rsp (строка 6).
Таким образом, rsp+8 в строке 4 и rsp+40h в остальной части кода — одно и то же значение.
Код довольно запутанный, т.к. в нём не применяется rbp.
В сообщении Access Violation есть целых две случайности:
- нули в старшей части адреса — там мог быть любой мусор;
- адрес случайно оказался некорректным.
Судя по всему, опция /RTCs включила затирание стека определёнными ненулевыми значениями, а сообщение Access Violation — лишь случайный побочный эффект.
Посмотрим, чем отличается код со включённой опцией /RTCs от кода без неё.
Код участков main() отличается только адресами локальных переменных на стеке.
(для наглядности я разместил рядом два варианта функции bad(int) — с /RTCs и без)
Без /RTCs исчезла инструкция rep stos и подготовка аргументов для неё в начале функции.
Пример 2a
Снова попробуем поуправлять неопределённым поведением. На этот раз только для одного компилятора.
Windows MSVC 2019 16.5.4, /Od /RTCs
С опцией /RTCs компилятор вставляет в начало функции bad(int) код, заполняющий младшую половину rax фиксированным значеним, что может приводить к Access violation.
Чтобы изменить это поведение, достаточно заполнить rax каким-либо корректным адресом.
Этого можно добиться очень простой модификацией: добавить в тело bad(int) вывод чего-нибудь в std::cout.
Полный код примера
#include <iostream>
#include <stdlib.h>
struct Test
{
Test(uint64_t v)
: value(v)
{
std::cout << "Test::Test(" << v << ")" << std::endl;
}
~Test()
{
std::cout << "Test::~Test()" << std::endl;
}
uint64_t value;
};
Test bad(int v)
{
std::cout << "rnd: " << v << std::endl;
if (v == 0) {
return {42};
} else if (v == 1) {
return {142};
}
}
int main()
{
const auto rnd = rand();
std::cout << bad(rnd).value << std::endl;
return 0;
}
rnd: 41
8791039331928
Test::~Test()
operator<< возвращает ссылку на stream, что реализуется как размещение адреса std::cout в rax. Адрес корректный, его можно разыменовывать. Access violation предотвращён.
Вывод
На простейших примерах нам удалось:
- собрать порядка 10 различных вариантов проявления неопределённого поведения;
- в подробностях узнать, как именно эти варианты будут исполняться.
Все компиляторы продемонстрировали чёткое следование Стандарту — ни в одном примере неопределённое поведение не началось раньше положенного. Но и в фантазии разработчикам компиляторов не откажешь.
Зачастую проявление зависит от тонких нюансов: стоит добавить или убрать одну, казалось бы, не относящуюся к делу строку кода — и поведение программы существенно меняется.
Очевидно, что проще не писать такой код, чем потом разгадывать ребусы.
Dr_Sigmund
Для варианта «Windows MSVC 2019 16.5.4, усложнённый пример, /O1, /O2» первого примера логика работы описана неправильно.
Нет. Там же не mov, а cmove — условное копирование, если ZF=1. Поэтому вывод
неверен. В stream выводится 1, если rand() вернул 0, а в противном случае выводится младший байт значения EBX, установленного в строке 6 командой «movzx ebx,byte ptr [rsp+30h]» — т.е. первый байт стека над адресом возврата.
Dubovik_a Автор
Спасибо, в самом деле, моя ошибка.
Исправил статью.
И дополнил в соответствии с возможностью поуправлять ещё одним возвращаемым значением.
Плюсик Вам в карму.
Dr_Sigmund
Спасибо!