Так, 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
- це 1-байтовий тип, який знову повинен мати ціле значення 0 або 1.
(ABI - це набір варіантів реалізації, про який погоджуються компілятори для однієї платформи, щоб вони могли робити код, який викликає функції один одного, включаючи розміри типів, правила компонування структури та конвенції викликів.)
ISO C ++ не визначає це, але це рішення ABI є широко поширеним, оскільки робить bool-> int перетворення дешевим (просто нульове розширення) . Мені невідомі жодні ABI, які не дозволяють компілятору вважати 0 або 1 bool
для будь-якої архітектури (не лише x86). Це дозволяє оптимізації , як !mybool
з xor eax,1
фліп молодший біт: Будь-який можливий код , який може перевернути біт / ціле / логічне значення між 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 ++ офіційно дозволяє вам безперервно представити об'єкти без будь-якого 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
на УБ, а навіть намагається генерувати код для шляхів виконання , які не мають ніякого сенсу. Або для таких випадків, як відпадання кінця нефункції void
, gcc іноді опускає ret
інструкцію. Якщо ви думали, що "моя функція просто повернеться з будь-яким сміттям у RAX", ви сильно помиляєтесь. Сучасні компілятори C ++ вже не ставляться до цієї мови, як до портативної мови складання. Ваша програма дійсно повинна бути дійсною C ++, не роблячи припущень про те, як може виглядати окрема неінлікована версія вашої функції в зоні.
Ще один цікавий приклад: Чому нестандартний доступ до пам'яті mmap'ed іноді є стандартним на AMD64? . x86 не винна у нерівних цілих числах, правда? То чому б несогласований uint16_t*
був проблемою? Тому що alignof(uint16_t) == 2
і порушення цього припущення призвело до сегментації під час автоматичної векторизації з SSE2.
Дивіться також те, що повинен знати кожен програміст на C щодо не визначеної поведінки №1 / 3 , статтю розробника кланг.
Ключовий момент: якщо компілятор помітив UB під час компіляції, він може "зламати" (випромінювати дивовижну asm) шлях через ваш код, який викликає UB, навіть якщо націлений на ABI, де будь-який біт-шаблон є дійсним представленням об'єкта bool
.
Очікуйте повну неприязнь до багатьох помилок програміста, особливо про те, про що попереджають сучасні компілятори. Ось чому ви повинні використовувати -Wall
та виправляти попередження. C ++ не є зручною для користувачів мовою, і щось на C ++ може бути небезпечним, навіть якщо це було б безпечно в Asm для цілі, яку ви збираєте. (наприклад, підписаний переповнення є UB в C ++, і компілятори будуть вважати, що цього не відбувається, навіть якщо компілюєте для 2-го додатка x86, якщо ви не використовуєте clang/gcc -fwrapv
.)
Унікальний UB, який бачить час, завжди небезпечний, і важко бути впевненим (оптимізуючи час зв’язку), що ви дійсно приховали UB від компілятора, і, таким чином, можете міркувати про те, який саме ASM він буде генерувати.
Не бути надмірно драматичним; часто компілятори дозволяють вам уникнути якихось речей і видавати код, як ви очікуєте, навіть коли щось є UB. Але, можливо, це буде проблемою у майбутньому, якщо розробники компілятора впровадять певну оптимізацію, яка отримує більше інформації про діапазони значень (наприклад, що змінна є негативною, можливо, дозволяє оптимізувати розширення знаків до вільного нульового розширення на x86- 64). Наприклад, у поточних gcc та clang виконання tmp = a+INT_MIN
не оптимізується a<0
як завжди-false, лише те, що tmp
завжди є негативним. (Оскільки INT_MIN
+ a=INT_MAX
є негативною для цілі доповнення цього 2, і a
не може бути вищою за це.)
Таким чином, gcc / clang в даний час не дає змоги отримати інформацію про діапазон для входів обчислення, лише на основі результатів на основі припущення про відсутність підписаного переповнення: приклад на Godbolt . Я не знаю, чи це оптимізація навмисно "пропущена" в ім'я зручності користувача чи що.
Також зауважте, що реалізаціям (aka компіляторам) дозволяється визначати поведінку, яку 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 біт можуть бути сміттям, яке повинен ігнорувати, як зазвичай, для типів, вужчих за регістр.
( Інші АБІС зробити зробити різні варіанти тут . Деякі з них вимагають цілих вузьких типів бути нульовими або знаковим розширенням для заповнення регістра при передачі або повернулися з функцій, таких як MIPS64 і PowerPC64 см. Останній розділ цього x86-64 відповіді який порівнює порівняно з попередніми МСА .)
Наприклад, абонент, можливо, розраховував a & 0x01010101
в RDI і використовував його для чогось іншого, перш ніж викликати bool_func(a&1)
. Абонент може оптимізувати цю функцію, &1
оскільки вона вже зробила це до низького байту and edi, 0x01010101
, і знає, що позивач повинен ігнорувати високі байти.
Або якщо bool передається як третій аргумент, можливо, абонент, що оптимізує розмір коду, завантажує його mov dl, [mem]
замість movzx edx, [mem]
, зберігаючи 1 байт за рахунок помилкової залежності від старого значення RDX (або іншого ефекту часткового реєстру, залежно від на моделі процесора). Або для першого аргументу mov dil, byte [r10]
замість цього movzx edi, byte [r10]
, оскільки обом потрібен префікс REX.
Саме тому брязкіт випромінює movzx eax, dil
в Serialize
, замість sub eax, edi
. (Для цілих аргументів, clang порушує це правило ABI, замість цього, залежно від недокументованої поведінки gcc і clang до нуля або знака-розширення вузьких цілих чисел до 32 біт. Потрібно розширення знака або нуля при додаванні зміщення на 32 біт до покажчика для x86-64 ABI?
Тому мені було цікаво побачити, що це не робить те саме bool
.)
Вимітка 2: Після розгалуження вам просто доведеться 4-байтовий mov
проміжний або 4-байтовий 1-байтовий запас. Довжина неявна в ширинах магазину + зміщення.
OTOH, glibc memcpy зробить два 4-байтові навантаження / сховища з перекриттям, що залежить від довжини, так що це дійсно в кінцевому підсумку робить всю річ вільною від умовних гілок на булі. Дивіться L(between_4_7):
блок у memcpy / memmove glibc. Або, принаймні, піти тим самим шляхом для будь-якого булевого розгалуження в 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/
Щоб знайти використання неініціалізованих даних, є адреса 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'smemcheck
також буде шукати подібну проблему, знову не скаржачись, якщо програма просто копіює навколо неініціалізованих даних. Але він говорить, що виявить, коли "Умовний стрибок або переміщення залежить від неініціалізованих значень (ів)", спробувати вловити будь-яку зовнішньо видиму поведінку, що залежить від неініціалізованих даних.
Можливо, ідея, що не позначається лише завантаженням, полягає в тому, що структури можуть мати набивання, а копіювання всієї структури (включаючи прокладку) з широким векторним завантаженням / сховищем не є помилкою, навіть якщо окремі члени були записані лише по одному. На рівні золи втрачена інформація про те, що було забито і що насправді є частиною значення.