Ответ 1
Да, ISO C++ позволяет (но не требует) реализации сделать этот выбор.
Но также обратите внимание, что ISO C++ позволяет компилятору генерировать код, который вылетает намеренно (например, с недопустимой инструкцией), если программа встречает UB, например, как способ помочь вам найти ошибки. (Или потому что это DeathStation 9000. Строго соответствующего соответствия недостаточно для того, чтобы реализация C++ была полезна для любых реальных целей). Таким образом, ISO C++ позволит компилятору создавать сбой asm (по совершенно разным причинам) даже в аналогичном коде, который читает неинициализированный uint32_t
. Даже если для этого требуется тип с фиксированной компоновкой с нет ловушек.
Это интересный вопрос о том, как работают реальные реализации, но помните, что даже если бы ответ был другим, ваш код все равно был бы небезопасен, потому что современный C++ не является переносимой версией языка ассемблера.
Вы компилируете для x86-64 System V ABI, в котором указано, что bool
как аргумент функции в регистре представлен битовыми шаблонами false=0
и true=1
в младших 8 битах регистра 1. В памяти bool
является однобайтовым типом, который снова должен иметь целочисленное значение 0 или 1.
(ABI - это набор вариантов реализации, с которыми согласуются компиляторы для одной и той же платформы, чтобы они могли создавать код, который вызывает функции друг друга, включая размеры шрифтов, правила структурирования и соглашения о вызовах.)
ISO C++ не определяет его, но это решение ABI широко распространено, потому что оно делает bool-> int преобразование дешевым (просто расширение zero-). Мне не известны никакие ABI, которые не позволяют компилятору принимать 0 или 1 для bool
, для любой архитектуры (не только для x86). Это позволяет оптимизировать, например, !mybool
с xor eax,1
, чтобы перевернуть младший бит: Любой возможный код, который может перевернуть бит/целое число /bool между 0 и 1 в одной инструкции ЦП. Или компилирование a&&b
в побитовое И для типов bool
. Некоторые компиляторы на самом деле используют логические значения как 8-битные в компиляторах. Являются ли операции с ними неэффективными?.
В общем, правило "как если" позволяет компилятору использовать преимущества истинного на целевой платформе, компилируемой для, потому что конечным результатом будет исполняемый код, который реализует то же внешне видимое поведение, что и C++ источник. (Со всеми ограничениями, которые Undefined Behavior накладывает на то, что на самом деле является "внешне видимым": не с помощью отладчика, а из другого потока в правильно сформированной/легальной программе C++.)
Компилятору определенно разрешено в полной мере использовать гарантию ABI в своем коде поколения и создавать код, подобный тому, который вы нашли, который оптимизирует strlen(whichString)
до
5U - boolValue
. (Кстати, эта оптимизация довольно умная, но может быть близорукой по сравнению с ветвлением и встраиванием memcpy
как хранилищ непосредственных данных 2.)
Или компилятор мог бы создать таблицу указателей и проиндексировать ее целочисленным значением bool
, снова предполагая, что это 0 или 1. (Эта возможность - то, что ответ @Barmar предложил.)
Ваш конструктор __attribute((noinline))
с включенной оптимизацией привел к лягушке, просто загружающей байт из стека для использования в качестве 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++ делает формально позволяет вам hexdump представления объектов без каких-либо UB. Приведение указателя для копирования представления объекта, конечно, отличается от присвоения char foo = my_bool
, поэтому логическое значение 0 или 1 не произойдет, и вы получите необработанное представление объекта.)
Вы частично "спрятали" UB на этом пути выполнения от компилятора с помощью noinline
. Тем не менее, даже если он не встроен, межпроцедурная оптимизация может сделать версию функции зависимой от определения другой функции. (Во-первых, clang создает исполняемый файл, а не разделяемую библиотеку Unix, где может происходить взаимное расположение символов. Во-вторых, определение внутри определения class{}
, поэтому все единицы перевода должны иметь одно и то же определение. Как и в ключевом слове inline
. )
Таким образом, компилятор может выдавать только ret
или ud2
(недопустимая инструкция) в качестве определения для main
, потому что путь выполнения, начинающийся с вершины main
, неизбежно встречает неопределенное поведение. (который во время компиляции компилятор может видеть, решил ли он следовать по пути через встроенный конструктор non-.)
Любая программа, которая сталкивается с UB, полностью не определена в течение всего ее существования. Но UB внутри функции или ветки if()
, которая никогда не запускается, не повреждает остальную часть программы. На практике это означает, что компиляторы могут решить выдать недопустимую инструкцию или ret
или не излучать что-либо и попасть в следующий блок/функцию, для всего базового блока, который может быть доказан во время компиляции, чтобы содержать или привести к UB.
GCC и Clang на практике действительно иногда испускают ud2
в UB, вместо того, чтобы даже пытаться сгенерировать код для путей выполнения, которые не имеют смысла. Или для случаев, таких как падение с конца non- void
функция, gcc иногда пропускает инструкцию ret
. Если вы думали, что "моя функция просто вернется с мусором в RAX", вы сильно ошибаетесь. Современные C++ компиляторы больше не рассматривают язык как переносимый язык ассемблера. Ваша программа действительно должна быть действительной C++, не делая предположений о том, как автономная не встроенная версия вашей функции может выглядеть в asm.
Еще один забавный пример: Почему при выравнивании доступа к памяти mmap & # 39 иногда происходит ошибка на AMD64?. x86 не ошибается на целых числах без выравнивания, верно? Так почему же возникнет смещение uint16_t*
? Потому что alignof(uint16_t) == 2
и нарушение этого предположения приводило к segfault при автоматической векторизации с SSE2.
См. также Чтодолжен знать каждый программист на C о неопределенном поведении # 1/3, статья разработчика Clang.
Ключевой момент: если компилятор заметил UB во время компиляции, он мог бы "прервать" (испустить удивительный asm) путь через ваш код, который вызывает UB, даже если он нацелен на ABI, где любой битовый шаблон является допустимым представлением объекта для bool
.
Ожидайте полной враждебности ко многим ошибкам со стороны программиста, особенно о том, о чем предупреждают современные компиляторы. Вот почему вы должны использовать -Wall
и исправлять предупреждения. C++ не является дружественным к пользователю языком, и что-то в C++ может быть небезопасным, даже если это будет безопасно в asm для цели, для которой вы компилируете. (например, переполнение со знаком равно UB в C++, и компиляторы предполагают, что этого не произойдет, даже при компиляции для 2-х дополнений x86, если вы не используете clang/gcc -fwrapv
.)
UB, видимый во время компиляции, всегда опасен, и очень трудно быть уверенным (с оптимизацией во время компоновки), что вы действительно скрыли UB от компилятора и, таким образом, можете решить, какой тип asm он будет генерировать.
Не быть чрезмерно драматичным; часто компиляторы позволяют вам сойтись с некоторыми вещами и генерировать код, как вы ожидаете, даже когда что-то не так. Но, возможно, это будет проблемой в будущем, если разработчики компиляторов реализуют некоторую оптимизацию, которая получает больше информации о диапазонах значений (например, переменная отрицательна non-, возможно, позволяя оптимизировать расширение знака для свободного расширения zero- на x86-64). Например, в текущих gcc и clang выполнение tmp = a+INT_MIN
не оптимизирует a<0
как всегда ложное, только то, что tmp
всегда отрицательно. (Потому что INT_MIN
+ a=INT_MAX
отрицателен для этой цели 2 дополнения, и a
не может быть выше этого уровня.)
Таким образом, gcc/clang в настоящее время не возвращается для получения информации о диапазоне для входных данных вычисления, а только на основе результатов, основанных на предположении об отсутствии переполнения со знаком: пример с Godbolt. Я не знаю, намеренно ли "пропущена" эта оптимизация во имя удобства для пользователя или как.
Также обратите внимание, что реализации (или компиляторы) могут определять поведение, которое ISO C++ оставляет неопределенным. Например, все компиляторы, которые поддерживают встроенные функции Intel (например, _mm_add_ps(__m128, __m128)
для ручной векторизации SIMD), должны позволять формировать неправильно выровненные указатели, что является UB в C++, даже если вы не разыменовываете их. __m128i _mm_loadu_si128(const __m128i *)
выполняет невыровненные нагрузки, беря неправильно выровненный аргумент __m128i*
, а не void*
или char*
. Является ли reinterpret_cast между аппаратным указателем вектора и соответствующим типом неопределенным поведением?
GNU C/C++ также определяет поведение сдвига влево отрицательного числа со знаком (даже без -fwrapv
), отдельно от обычных правил UB со знаком переполнения со знаком. (Это UB в ISO C++, в то время как правые сдвиги чисел со знаком определяются реализацией (логическое или арифметическое); реализации хорошего качества выбирают арифметику в HW, которая имеет арифметические правые сдвиги, но в ISO C++ не уточняется). Это задокументировано в разделе Integer в руководстве GCC вместе с определением поведения, определяемого реализацией, которое стандарты C требуют, чтобы реализации определяли так или иначе.
Определенно есть проблемы с качеством реализации, о которых заботятся разработчики компиляторов; как правило, они не пытаются сделать компиляторы преднамеренно враждебными, но использование всех ухабов UB в C++ (кроме тех, которые они выбирают для оптимизации) иногда может быть почти неразличимым.
Сноска 1: старшие 56 битов могут быть мусором, который вызывающий должен игнорировать, как обычно для типов, более узких, чем регистр.
(Другие ABI здесь делают другой выбор. Некоторые требуют, чтобы узкие целочисленные типы были zero- или расширены знаком для заполнения регистра при передаче или возвращении из функций, таких как MIPS64 и PowerPC64. См. последний раздел этого ответа x86-64, который сравнивается с теми более ранними ISA.)
Например, вызывающий абонент мог вычислить 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 до zero- или расширения знака узкими целыми числами до 32 бит. Требуется ли расширение знака или нуля при добавлении 32-битного смещения на указатель для ABI x86-64?
Поэтому мне было интересно увидеть, что он не делает то же самое для bool
.)
Сноска 2: После ветвления у вас будет 4-байтовое хранилище mov
-immediate или 4-байтовое хранилище + 1 байт. Длина указана в значениях ширины магазина + смещения.
OTOH, glibc memcpy сделает две 4-байтовые загрузки/хранилища с перекрытием, зависящим от длины, так что это действительно в конечном итоге делает все это свободным от условных ветвей в логическом значении. Смотрите блок L(between_4_7):
в glibc memcpy/memmove. Или, по крайней мере, используйте тот же способ для логического значения в ветвлении memcpy, чтобы выбрать размер фрагмента.
При встраивании вы можете использовать 2x mov
-immediate + 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 информация о том, что было дополнением и что на самом деле является частью значения, была потеряна.