Исследование одного неопределённого поведения
В статье исследуются возможные проявления неопределённого поведения, возникающего в 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:
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
bool bad() {};
int main()
{
std::cout << bad();
return 0;
}
MSVC выдаёт на такой пример ошибку компиляции C4716, поэтому для MSVC код придётся слегка усложнить, предоставив хотя бы один корректный путь выполнения:
#include
#include
bool bad()
{
if (rand() == 0) {
return true;
}
}
int main()
{
std::cout << bad();
return 0;
}
Компиляция:
Результаты выполнения:
Даже в этом простейшем примере четыре компилятора продемонстрировали как минимум три варианта проявления неопределённого поведения.
Идём разбираться, что же там эти компиляторы накомпилировали.
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 ():
— вызывает rand () (строка 8);
— независимо от значения, возвращённого rand (), записывает единицу в регистр ecx (строка 10);
— копирует это же значение в ebx (строка 11);
— копирует это же значение в dl (точнее, его младший байт) (строка 13);
— вызывает функцию вывода в stream, осуществляющую вывод значения dl (строка 14).
В stream выводится единица.
Вывод по примеру 1
Результаты рассмотрения листингов дизассемблера приведены в таблице:
Как оказалось, компиляторы продемонстрировали не 3, а целых 6 вариантов неопределённого поведения — просто до рассмотрения листингов дизассемблера мы не могли различить некоторые из них.
Пример 1a — управление неопределённым поведением
Попробуем немного порулить неопределённым поведением — повлиять на значение, возвращаемое функцией bad ().
Это можно проделать только с теми компиляторами, которые выводят мусор.
Для этого надо подсовывать желаемые значения в те места, из которых компиляторы их будут брать.
Linux x86-x64 gcc 9.3, -O0
Пустая функция bad () не модифицирует значение регистра al, как от неё требует вызывающий код. Таким образом, если мы разместим в al определённое значение до вызова bad (), то ожидаем увидеть именно это значение в качестве результата выполнения bad ().
Очевидно, что это можно сделать с помощью вызова любой другой функции, возвращающей bool. Но также это можно сделать с помощью функции, возвращающей, например, unsinged char.
#include
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
#include
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
macOs X Apple clang version 11.0.0, -O0
Надо перед вызовом bad () вписать определённое значение в ту ячейку памяти, которая будет на единицу младше вершины стека в момент вызова bad ().
#include
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
#include
#include
#include
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{badBool1, badBool2, true, false}.size(): "
<< std::set{badBool1, badBool2, true, false}.size()
<< std::endl;
std::cout << "std::unordered_set{badBool1, badBool2, true, false}.size(): "
<< std::unordered_set{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{badBool1, badBool2, true, false}.size (): 4
std: unordered_set{badBool1, badBool2, true, false}.size (): 4
Неопределённое поведение привело к возникновению булевской переменной, которая ломает как минимум:
— операторы сравнения булевских значений;
— хеш-функцию булевского значения.
Linux x86-x64 gcc 9.3, -O0
#include
#include
#include
#include
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{badBool1, badBool2, true, false}.size(): "
<< std::set{badBool1, badBool2, true, false}.size()
<< std::endl;
std::cout << "std::unordered_set{badBool1, badBool2, true, false}.size(): "
<< std::unordered_set{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{badBool1, badBool2, true, false}.size (): 4
std: unordered_set{badBool1, badBool2, true, false}.size (): 4
По сравнению с MSVC, в gcc добавилась ещё и некорректная работа оператора not.
Вывод по примеру 1b
Нарушение работы базовых операций с булевскими значениями может иметь серьёзные последствия для высокоуровневой логики.
Почему так произошло?
Потому что некоторые операции с булевскими переменными реализованы в предположении, что true — это строго единица.
В дизассемблере этот вопрос рассматривать не будем — статья и так получилась объёмной.
В очередной раз уточним таблицу с поведением компиляторов:
Четыре компилятора дали 7 различных проявлений неопределённого поведения.
Пример 2 — struct
Возьмём пример чуть посложнее:
#include
#include
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) имеет два корректных пути выполнения, ни один из которых не будет реализован при единственном вызове.
На этот раз — сначала таблица, потом разбор дизассемблера по непонятным пунктам.
Опять мы видим множество вариантов: кроме уже известного 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
#include
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
#include
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
Вывод
На простейших примерах нам удалось:
— собрать порядка 10 различных вариантов проявления неопределённого поведения;
— в подробностях узнать, как именно эти варианты будут исполняться.
Все компиляторы продемонстрировали чёткое следование Стандарту — ни в одном примере неопределённое поведение не началось раньше положенного. Но и в фантазии разработчикам компиляторов не откажешь.
Зачастую проявление зависит от тонких нюансов: стоит добавить или убрать одну, казалось бы, не относящуюся к делу строку кода — и поведение программы существенно меняется.
Очевидно, что проще не писать такой код, чем потом разгадывать ребусы.