Допускает ли стандарт C++ неинициализированный bool для сбоя программы?

Я знаю, что "неопределенное поведение" в C++ может позволить компилятору делать все, что он захочет. Однако у меня произошел сбой, который удивил меня, так как я предположил, что код был достаточно безопасным.

В этом случае настоящая проблема возникла только на конкретной платформе, использующей определенный компилятор, и только если была включена оптимизация.

Я перепробовал несколько вещей, чтобы воспроизвести проблему и максимально упростить ее. Вот выдержка из функции под названием Serialize, что бы взять параметр bool и скопировать строку true или же false в существующий целевой буфер.

Если бы эта функция была в обзоре кода, не было бы никакого способа сказать, что она на самом деле могла бы аварийно завершиться, если бы параметр bool был неинициализированным значением?

// Zero-filled global buffer of 16 characters
char destBuffer[16];

void Serialize(bool boolValue) {
    // Determine which string to print based on boolValue
    const char* whichString = boolValue ? "true" : "false";

    // Compute the length of the string we selected
    const size_t len = strlen(whichString);

    // Copy string into destination buffer, which is zero-filled (thus already null-terminated)
    memcpy(destBuffer, whichString, len);
}

Если этот код выполняется с оптимизацией clang 5.0.0 +, он может / может дать сбой.

Ожидаемый троичный оператор boolValue ? "true" : "false" выглядел достаточно безопасным для меня, я предполагал, "Какая бы ни была ценность мусора boolValue не имеет значения, так как в любом случае он будет иметь значение true или false."

Я настроил пример Compiler Explorer, который показывает проблему в разборке, вот полный пример. Примечание: чтобы воспроизвести проблему, я обнаружил, что сработала комбинация с использованием Clang 5.0.0 с оптимизацией -O2.

#include <iostream>
#include <cstring>

// Simple struct, with an empty constructor that doesn't initialize anything
struct FStruct {
    bool uninitializedBool;

   __attribute__ ((noinline))  // Note: the constructor must be declared noinline to trigger the problem
   FStruct() {};
};

char destBuffer[16];

// Small utility function that allocates and returns a string "true" or "false" depending on the value of the parameter
void Serialize(bool boolValue) {
    // Determine which string to print depending if 'boolValue' is evaluated as true or false
    const char* whichString = boolValue ? "true" : "false";

    // Compute the length of the string we selected
    size_t len = strlen(whichString);

    memcpy(destBuffer, whichString, len);
}

int main()
{
    // Locally construct an instance of our struct here on the stack. The bool member uninitializedBool is uninitialized.
    FStruct structInstance;

    // Output "true" or "false" to stdout
    Serialize(structInstance.uninitializedBool);
    return 0;
}

Проблема возникает из-за оптимизатора: было достаточно умно сделать вывод, что строки "истина" и "ложь" отличаются только по длине на 1. Поэтому вместо реального вычисления длины он использует значение самого bool, которое должно технически это может быть 0 или 1, и выглядит так:

const size_t len = strlen(whichString); // original code
const size_t len = 5 - boolValue;       // clang clever optimization

Хотя это, так сказать, "умно", мой вопрос таков: позволяет ли стандарт C++ компилятору предполагать, что bool может иметь только внутреннее числовое представление "0" или "1" и использовать его таким образом?

Или это случай, определяемый реализацией, и в этом случае реализация предполагала, что все ее значения bool будут содержать только 0 или 1, а любое другое значение является неопределенной территорией поведения?

6 ответов

Решение

Да, ISO C++ позволяет (но не требует) реализации сделать этот выбор.

Но также обратите внимание, что ISO C++ позволяет компилятору генерировать код, который вылетает намеренно (например, с недопустимой инструкцией), если программа сталкивается с UB, например, как способ помочь вам найти ошибки. (Или потому, что это DeathStation 9000. Строго соответствующего соответствия недостаточно, чтобы реализация C++ была полезна для любых реальных целей). Таким образом, ISO C++ позволит компилятору создавать сбой asm (по совершенно другим причинам) даже в аналогичном коде, который читает неинициализированный uint32_t , Даже при том, что это должен быть тип с фиксированной компоновкой без представлений ловушек.

Это интересный вопрос о том, как работают реальные реализации, но помните, что даже если бы ответ был другим, ваш код все равно был бы небезопасным, потому что современный C++ не является переносимой версией ассемблера.


Вы компилируете для x86-64 System V ABI, который указывает, что bool как функция arg в регистре представлена ​​битовыми паттернами false=0 а также true=1 в младших 8 битах регистра 1. В памяти, bool является 1-байтовым типом, который снова должен иметь целочисленное значение 0 или 1.

(ABI - это набор вариантов реализации, с которыми согласуются компиляторы для одной и той же платформы, чтобы они могли создавать код, который вызывает функции друг друга, включая размеры типов, правила структурирования и соглашения о вызовах.)

ISO C++ не определяет его, но это решение ABI широко распространено, потому что оно делает преобразование bool->int дешевым (просто с нулевым расширением). Я не знаю ни одного ABI, которые не позволяют компилятору принимать 0 или 1 для bool, для любой архитектуры (не только x86). Это позволяет оптимизации как !mybool с xor eax,1 перевернуть младший бит: Любой возможный код, который может перевернуть бит / целое число /bool между 0 и 1 в одной инструкции CPU. Или составление a&&b побитовое И для bool типы. Некоторые компиляторы действительно используют булевские значения как 8-битные в компиляторах. Операции на них неэффективны?,

В общем, правило "как если" позволяет компилятору использовать преимущества, которые являются истинными для целевой платформы, для которой выполняется компиляция, поскольку конечным результатом будет исполняемый код, который реализует то же внешне видимое поведение, что и исходный код C++. (Со всеми ограничениями, которые Undefined Behavior накладывает на то, что на самом деле "внешне видно": не с помощью отладчика, а из другого потока в правильно сформированной / легальной программе C++.)

Компилятору определенно разрешено в полной мере воспользоваться гарантией ABI в своем коде поколения и создавать код, подобный найденному, который оптимизирует strlen(whichString) в
5U - boolValue , (Кстати, эта оптимизация довольно умна, но, возможно, близорука против ветвления и встраивания memcpy как хранилища непосредственных данных 2.)

Или компилятор мог создать таблицу указателей и проиндексировать ее целочисленным значением bool, снова предполагая, что это было 0 или 1. ( Эта возможность - то, что предложил ответ @ Бармара.)


Ваш __attribute((noinline)) Конструктор с включенной оптимизацией привел к тому, что Clang просто загружает байт из стека для использования в качестве uninitializedBool, Это освободило место для объекта в main с push rax (который меньше и по разным причинам столь же эффективен, как sub rsp, 8), так что мусор был в AL при входе в main это значение, которое он использовал для uninitializedBool, Вот почему вы на самом деле получили ценности, которые были не просто 0,

5U - random garbage может легко переносить большие значения без знака, что приводит к тому, что memcpy попадает в неотображенную память. Место назначения находится в статическом хранилище, а не в стеке, поэтому вы не перезаписываете адрес возврата или что-то еще.


Другие реализации могут сделать другой выбор, например false=0 а также true=any non-zero value , Тогда, вероятно, clang не создаст код, который вылетает для этого конкретного экземпляра UB. (Но все равно было бы разрешено, если бы захотелось.) Я не знаю ни одной реализации, которая бы выбирала что-то другое, для чего x86-64 делает bool, но стандарт C++ допускает многие вещи, которые никто не делает или даже не хочет делать на оборудовании, которое похоже на современные процессоры.

ISO C++ оставляет неопределенным, что вы найдете, когда вы исследуете или изменяете объектное представление bool, (например, memcpy в bool в unsigned char, что вам разрешено делать, потому что char* может псевдоним что угодно. А также unsigned char гарантированно не имеет битов заполнения, поэтому стандарт C++ формально разрешает вам шестнадцатеричное представление объектов без UB. Приведение указателя для копирования представления объекта отличается от назначения char foo = my_bool Конечно, логическое значение 0 или 1 не произойдет, и вы получите представление необработанного объекта.)

Вы частично "спрятали" UB на этом пути выполнения от компилятора с noinline, Однако, даже если он не встроен, межпроцедурная оптимизация может сделать версию функции зависимой от определения другой функции. (Во-первых, clang создает исполняемый файл, а не разделяемую библиотеку Unix, где может происходить взаимное расположение символов. Во-вторых, определение внутри class{} определение, поэтому все единицы перевода должны иметь одинаковое определение. Как с inline ключевое слово.)

Таким образом, компилятор может выдавать только ret или же ud2 (незаконная инструкция) как определение для main потому что путь выполнения начинается с вершины main неизбежно сталкивается с неопределенным поведением. (Что может видеть компилятор во время компиляции, если он решил следовать по пути через не встроенный конструктор.)

Любая программа, которая сталкивается с UB, полностью не определена в течение всего ее существования. Но UB внутри функции или if() ветвь, которая никогда не запускается, не портит остальную часть программы. На практике это означает, что компиляторы могут решить создать недопустимую инструкцию или ret или не генерировать что-либо и попадать в следующий блок / функцию, для всего базового блока, который может быть доказан во время компиляции, чтобы содержать или привести к UB.

GCC и Clang на практике иногда излучают ud2 на UB вместо того, чтобы даже пытаться генерировать код для путей выполнения, которые не имеют смысла. Или для случаев, таких как падение с конца void функция, GCC будет иногда опускать ret инструкция. Если вы думали, что "моя функция просто вернется с мусором в RAX", вы сильно ошибаетесь. Современные компиляторы C++ больше не рассматривают язык как переносимый язык ассемблера. Ваша программа действительно должна быть верной C++, не делая предположений о том, как автономная не встроенная версия вашей функции может выглядеть в asm.

Еще один забавный пример: почему невыравниваемый доступ к памяти mmap иногда вызывает ошибку на AMD64?, x86 не ошибается на невыровненных целых числах, верно? Так почему бы сместиться uint16_t* быть проблемой? Так как alignof(uint16_t) == 2 и нарушение этого предположения привело к возникновению ошибки при автоматической векторизации с SSE2.

Смотрите также, что должен знать каждый программист на C о неопределенном поведении # 1/3, статья разработчика Clang.

Ключевой момент: если компилятор заметил UB во время компиляции, он мог бы "прервать" (испустить удивительный asm) путь через ваш код, который вызывает UB, даже если он нацелен на ABI, где любой битовый шаблон является допустимым представлением объекта для bool,

Ожидайте полной враждебности ко многим ошибкам со стороны программиста, особенно о том, о чем предупреждают современные компиляторы. Вот почему вы должны использовать -Wall и исправить предупреждения. C++ не является дружественным к пользователю языком, и что-то в C++ может быть небезопасным, даже если это будет безопасно в asm для цели, для которой вы компилируете. (например, переполнение со знаком - это UB в C++, и компиляторы предполагают, что этого не произойдет, даже при компиляции для дополнения x86 к 2, если только вы не используете clang/gcc -fwrapv.)

UB, видимый во время компиляции, всегда опасен, и очень трудно быть уверенным (с оптимизацией во время компоновки), что вы действительно скрыли UB от компилятора и, таким образом, можете решить, какой тип asm он сгенерирует.

Не быть чрезмерно драматичным; часто компиляторы позволяют вам сойтись с некоторыми вещами и генерировать код, как вы ожидаете, даже когда что-то не так. Но, возможно, это будет проблемой в будущем, если разработчики компиляторов реализуют некоторую оптимизацию, которая получает больше информации о диапазонах значений (например, переменная неотрицательна, возможно, позволяя оптимизировать расширение знака для свободного расширения нуля на x86-64). Например, в текущем gcc и clang, делая tmp = a+INT_MIN не позволяет им оптимизировать a<0 как всегда - правда, только то, что tmp всегда отрицательно. (Таким образом, они не возвращаются от входных данных вычисления для получения информации о диапазоне, только на результатах, основанных на допущении отсутствия переполнения со знаком: пример на Godbolt. Я не знаю, является ли это преднамеренным удобством для пользователя или просто пропустил оптимизацию.)

Также обратите внимание, что реализации (или компиляторы) могут определять поведение, которое ISO C++ оставляет неопределенным. Например, все компиляторы, которые поддерживают встроенные функции Intel (например, _mm_add_ps(__m128, __m128) для ручной векторизации SIMD) необходимо разрешить формирование неправильно выровненных указателей, что является UB в C++, даже если вы не разыменовываете их. __m128i _mm_loadu_si128(const __m128i *) делает выровненные нагрузки, принимая выровненные __m128i* а не void* или же char*, Является ли `reinterpret_cast`ing между аппаратным указателем вектора и соответствующим типом неопределенным поведением?

GNU C / C++ также определяет поведение сдвига влево отрицательного числа со знаком (даже без -fwrapv), отдельно от обычных правил UB со знаком переполнения. ( Это UB в ISO C++, в то время как правое смещение чисел со знаком определяется реализацией (логическое или арифметическое); реализации хорошего качества выбирают арифметику на HW, которая имеет арифметическое правое смещение, но ISO C++ не определяет). Это задокументировано в разделе Integer руководства GCC, а также определено поведение, определяемое реализацией, которое стандарты C требуют, чтобы реализации определяли так или иначе.

Определенно есть проблемы с качеством реализации, о которых заботятся разработчики компиляторов; они обычно не пытаются сделать компиляторы преднамеренно враждебными, но использование всех пробелов UB в C++ (кроме тех, которые они выбирают для оптимизации) иногда может быть почти неразличимым.


Сноска 1: старшие 56 битов могут быть мусором, который вызывающий должен игнорировать, как обычно для типов, более узких, чем регистр.

(Другие ABI здесь делают другой выбор. Некоторые требуют, чтобы узкие целочисленные типы были расширены нулями или знаками для заполнения регистра при передаче или возвращении из функций, таких как MIPS64 и PowerPC64. См. Последний раздел этого ответа x86-64. который сравнивается с теми более ранними МСА.)

Например, абонент мог рассчитать a & 0x01010101 в RDI и использовал его для чего-то другого, перед вызовом bool_func(a&1), Абонент может оптимизировать &1 потому что он уже сделал это с младшим байтом как часть and edi, 0x01010101 и он знает, что вызываемый объект должен игнорировать старшие байты.

Или, если bool передан в качестве 3-го аргумента, возможно, вызывающий, оптимизирующий под размер кода, загружает его mov dl, [mem] вместо movzx edx, [mem] сохранение 1 байта за счет ложной зависимости от старого значения RDX (или другого эффекта частичного регистра, в зависимости от модели процессора). Или для первого аргумента, mov dil, byte [r10] вместо movzx edi, byte [r10] потому что оба требуют префикс REX в любом случае.

Вот почему Clang испускает movzx eax, dil в Serialize, вместо sub eax, edi, (Для целочисленных аргументов clang нарушает это правило ABI, вместо этого в зависимости от недокументированного поведения gcc и clang до нуля или знака расширяет узкие целые числа до 32 бит. Требуется ли расширение знака или нуля при добавлении 32-битного смещения к указателю для ABI x86-64? Так что мне было интересно увидеть, что он не делает то же самое для bool.)


Сноска 2: После ветвления у вас будет только 4 байта mov Ближайший или 4-байтовый + 1-байтовый магазин. Длина указана в значениях ширины магазина + смещения.

OTOH, glibc memcpy сделает две 4-байтовые загрузки / хранилища с перекрытием, зависящим от длины, так что это действительно в конечном итоге делает все это свободным от условных ветвей в логическом значении. Увидеть L(between_4_7): блок в memcpy / memmove glibc. Или, по крайней мере, используйте тот же способ для логического значения в ветвлении memcpy, чтобы выбрать размер куска.

Если встраивание, вы можете использовать 2x mov Ближайший + cmov и условное смещение, или вы можете оставить строковые данные в памяти.

Или при настройке на Intel Ice Lake ( с функцией Fast Short REP MOV) актуальным rep movsb может быть оптимальным. Glibc memcpy может начать использовать rep movsb для небольших размеров на процессорах с этой функцией, сохраняя большое количество ветвлений.


Инструменты для обнаружения UB и использования неинициализированных значений

В gcc и clang вы можете скомпилировать -fsanitize=undefined добавить инструментарий времени выполнения, который будет предупреждать или сообщать об ошибке в UB, которая происходит во время выполнения. Это не поймает унифицированные переменные, хотя. (Потому что он не увеличивает размеры шрифта, чтобы освободить место для "неинициализированного" бита).

См. https://developers.redhat.com/blog/2014/10/16/gcc-undefined-behavior-sanitizer-ubsan/

Чтобы найти использование неинициализированных данных, есть Address Sanitizer и Memory Sanitizer в clang / LLVM. https://github.com/google/sanitizers/wiki/MemorySanitizer показывает примеры clang -fsanitize=memory -fPIE -pie обнаружение неинициализированных операций чтения из памяти. Это может работать лучше, если вы компилируете без оптимизации, поэтому все чтения переменных в конечном итоге фактически загружаются из памяти в asm. Они показывают, что это используется в -O2 в случае, когда нагрузка не оптимизируется. Я сам не пробовал. (В некоторых случаях, например, не инициализируя аккумулятор перед суммированием массива, clang -O3 будет выдавать код, который суммируется в векторный регистр, который он никогда не инициализировал. Так что с оптимизацией вы можете иметь случай, когда нет чтения памяти, связанной с UB. Но -fsanitize=memory изменяет сгенерированный asm и может привести к проверке.)

Это допустит копирование неинициализированной памяти, а также простые логические и арифметические операции с ней. В общем, MemorySanitizer молча отслеживает распространение неинициализированных данных в памяти и выдает предупреждение, когда ветвь кода берется (или не берется) в зависимости от неинициализированного значения.

MemorySanitizer реализует подмножество функций, найденных в Valgrind (инструмент Memcheck).

Это должно работать для этого случая, потому что вызов glibc memcpy с length рассчитывается из неинициализированной памяти (внутри библиотеки) приведет к ветви на основе length, Если бы он указал полностью версию без ответвлений, которая только что использовала cmov, индексация и два магазина, это могло бы не сработать.

valgrind для memcheck также будет искать такую ​​проблему, опять же, не жалуясь, если программа просто копирует неинициализированные данные. Но он говорит, что обнаружит, когда "условный переход или перемещение зависит от неинициализированных значений", чтобы попытаться отследить любое внешне видимое поведение, которое зависит от неинициализированных данных.

Возможно, идея не отмечать только загрузку состоит в том, что структуры могут иметь заполнение, и копирование всей структуры (включая заполнение) с широкой векторной загрузкой / сохранением не является ошибкой, даже если отдельные элементы были записаны только по одному за раз. На уровне asm информация о том, что было дополнением и что на самом деле является частью значения, была потеряна.

Компилятору разрешается предполагать, что логическое значение, переданное в качестве аргумента, является допустимым логическим значением (то есть тем, которое было инициализировано или преобразовано в true или же false). true значение не должно совпадать с целым числом 1 - действительно, могут быть различные представления true а также false - но параметр должен быть некоторым допустимым представлением одного из этих двух значений, где "действительное представление" определяется реализацией.

Так что если вы не можете инициализировать boolили если вам удастся перезаписать его с помощью какого-либо указателя другого типа, то предположения компилятора будут неверными, и последует неопределенное поведение. Вы были предупреждены:

50) Использование значения bool способами, описанными в этом международном стандарте как "неопределенные", например, путем проверки значения неинициализированного автоматического объекта, может привести к тому, что он будет вести себя так, как если бы он не был ни истинным, ни ложным. (Сноска к пункту 6 §6.9.1, Основные типы)

Сама функция корректна, но в вашей тестовой программе оператор, вызывающий функцию, вызывает неопределенное поведение, используя значение неинициализированной переменной.

Ошибка заключается в вызывающей функции, и она может быть обнаружена путем проверки кода или статического анализа вызывающей функции. Используя ссылку на проводник компилятора, компилятор gcc 8.2 обнаруживает ошибку. (Может быть, вы могли бы подать отчет об ошибке в Clang, что он не находит проблему).

Неопределенное поведение означает, что может произойти все что угодно, включая сбой программы через несколько строк после события, которое вызвало неопределенное поведение.

NB. Ответ на вопрос "Может ли неопределенное поведение вызвать _____?" всегда "да". Это буквально определение неопределенного поведения.

Бул разрешается хранить только значения 0 или же 1и сгенерированный код может предполагать, что он будет содержать только одно из этих двух значений. Код, сгенерированный для троичной переменной в присваивании, может использовать значение в качестве индекса в массиве указателей на две строки, то есть его можно преобразовать во что-то вроде:

     // the compile could make asm that "looks" like this, from your source
const static char *strings[] = {"false", "true"};
const char *whichString = strings[boolValue];

Если boolValue является неинициализированным, он может фактически содержать любое целочисленное значение, которое затем будет вызывать доступ за пределы strings массив.

Резюмируя ваш вопрос, вы спрашиваете, позволяет ли стандарт C++ компилятору принимать bool может иметь только внутреннее числовое представление "0" или "1" и использовать его таким образом?

Стандарт ничего не говорит о внутреннем представлении bool, Он определяет только то, что происходит при bool для int (или наоборот). Главным образом, из-за этих интегральных преобразований (и того факта, что люди довольно сильно на них полагаются), компилятор будет использовать 0 и 1, но это не обязательно (хотя он должен учитывать ограничения любого ABI более низкого уровня, который он использует).

Итак, компилятор, когда он видит bool имеет право считать, что сказал bool содержит любой изtrue' или же 'falseБит, и делай все, что хочется. Так что, если значения для true а также false равны 1 и 0, компилятору действительно разрешено оптимизировать strlen в 5 - <boolean value>, Другие забавные поведения возможны!

Как неоднократно указывается здесь, неопределенное поведение имеет неопределенные результаты. В том числе, но не ограничивается

  • Ваш код работает так, как вы ожидали
  • Ваш код не работает в случайное время
  • Ваш код вообще не запускается.

Посмотрите, что каждый программист должен знать о неопределенном поведении

Позволяет ли стандарт С++ компилятору предположить, что логическое значение может иметь только внутреннее числовое представление «0» или «1» и использовать его таким образом?

Да, действительно, и на случай, если это будет кому-то полезно, вот еще один пример из реальной жизни, который произошел со мной.

Однажды я потратил несколько недель на поиск малоизвестной ошибки в большой кодовой базе. Было несколько аспектов, которые делали это сложным, но основной причиной был неинициализированный логический член переменной класса.

Был тест со сложным выражением, включающим эту переменную-член:

      if(COMPLICATED_EXPRESSION_INVOLVING(class->member)) {
    ...
}

Я начал подозревать, что этот тест не оценивал «истинно», когда должен был. Я не помню, то ли было неудобно запускать вещи под отладчиком, то ли я не доверял отладчику, то ли еще что, но я пошел на метод грубой силы, дополнив код некоторыми отладочными распечатками:

      printf("%s\n", COMPLICATED_EXPRESSION_INVOLVING(class->member) ? "yes" : "no");

if(COMPLICATED_EXPRESSION_INVOLVING(class->member)) {
    printf("doing the thing\n");
    ...
}

Каково же было мое удивление, когда код напечатался " " с последующим " ".

Дизассемблирование показало, что иногда компилятор (которым был gcc) проверял логический член, сравнивая его с 0, но в других случаях он использовал инструкцию проверки наименьшего значащего бита. Неинициализированная логическая переменная оказалась равной 2. Таким образом, на машинном языке проверка эквивалентна

      if(class->member != 0)

успешно, но тест, эквивалентный

      if(class->member % 2 != 0)

не удалось. Переменная была буквально истинной и ложной одновременно! И если это не неопределенное поведение, то я не знаю, что это такое!

Другие вопросы по тегам