Tak, ISO C ++ pozwala (ale nie wymaga) implementacji, aby dokonać tego wyboru.
Należy jednak pamiętać, że ISO C ++ pozwala kompilatorowi na celowe emitowanie kodu, który ulega awarii (np. Z niedozwoloną instrukcją), jeśli program napotka UB, np. Jako sposób na pomoc w znalezieniu błędów. (Lub dlatego, że jest to DeathStation 9000. Ścisłe przestrzeganie nie wystarcza, aby implementacja C ++ była przydatna w jakimkolwiek rzeczywistym celu). Tak więc ISO C ++ pozwoliłoby kompilatorowi wykonać asm, który się zawiesił (z zupełnie innych powodów) nawet na podobnym kodzie, który czyta niezainicjowany uint32_t
. Mimo że jest to wymagany typ układu o stałym układzie bez reprezentacji pułapek.
To interesujące pytanie o to, jak działają rzeczywiste implementacje, ale pamiętaj, że nawet gdyby odpowiedź była inna, twój kod byłby nadal niebezpieczny, ponieważ nowoczesny C ++ nie jest przenośną wersją języka asemblera.
Kompilujesz dla systemu AB86 x86-64 System V. , który określa, że bool
jako argument arg w rejestrze jest reprezentowany przez wzorce bitowe false=0
itrue=1
w 8 niskich bitach rejestru 1 . W pamięci bool
jest typ 1-bajtowy, który ponownie musi mieć wartość całkowitą 0 lub 1.
(ABI to zestaw opcji implementacyjnych, na które zgadzają się kompilatory dla tej samej platformy, dzięki czemu mogą tworzyć kod, który wywołuje nawzajem funkcje, w tym rozmiary typów, reguły układu struktur i konwencje wywoływania.)
ISO C ++ tego nie określa, ale ta decyzja ABI jest szeroko rozpowszechniona, ponieważ sprawia, że konwersja bool-> int jest tania (tylko rozszerzenie zerowe) . Nie znam żadnych ABI, które nie pozwalają kompilatorowi przyjąć 0 lub 1 bool
dla dowolnej architektury (nie tylko x86). Pozwala na optymalizacje typu !mybool
z xor eax,1
odwracaniem niskiego bitu: Dowolny możliwy kod, który może odwrócić bit / liczbę całkowitą / bool od 0 do 1 w instrukcji pojedynczego procesora . Lub kompilacja a&&b
do bitowego AND dla bool
typów. Niektóre kompilatory faktycznie wykorzystują w kompilatorach wartości boolowskie jako 8 bitów. Czy operacje na nich są nieefektywne? .
Zasadniczo zasada „tak, jakby” pozwala kompilatorowi korzystać z rzeczy, które są prawdziwe na kompilowanej platformie docelowej , ponieważ wynikiem końcowym będzie kod wykonywalny, który implementuje to samo widoczne z zewnątrz zachowanie, co źródło C ++. (Ze wszystkimi ograniczeniami, które Undefined Behawiior nakłada na to, co jest faktycznie „widoczne z zewnątrz”: nie za pomocą debuggera, ale z innego wątku w dobrze uformowanym / legalnym programie C ++.)
Kompilator jest zdecydowanie możliwość pełnego wykorzystania gwarancji ABI w jego kodzie-gen, i uczynić kod jak znalazłeś który optymalizuje strlen(whichString)
się
5U - boolValue
. (BTW, ta optymalizacja jest dość sprytna, ale może krótkowzroczna vs. rozgałęzienie i inline memcpy
jako zapasy natychmiastowych danych 2 ).
Lub kompilator mógł utworzyć tabelę wskaźników i zindeksować ją wartością całkowitą bool
, ponownie zakładając, że jest to 0 lub 1. ( Ta możliwość sugeruje odpowiedź @ Barmar ).
Twój __attribute((noinline))
konstruktor z włączoną optymalizacją doprowadził do tego, że clang właśnie ładował bajt ze stosu, aby użyć go jako uninitializedBool
. Wykonana miejsca dla obiektu w main
z push rax
(który jest mniejszy i dla rozmaitych powodów o tak skuteczny jak sub rsp, 8
), więc niezależnie od śmieci było w AL na wejściu do main
jest wartością go stosuje uninitializedBool
. Właśnie dlatego masz wartości, które nie były tylko 0
.
5U - random garbage
może łatwo zawinąć do dużej wartości bez znaku, co prowadzi memcpy do przejścia do niezmapowanej pamięci. Miejsce docelowe znajduje się w pamięci statycznej, a nie na stosie, więc nie zastępujesz adresu zwrotnego ani czegoś takiego.
Inne implementacje mógł dokonać różnych wyborów, EG false=0
i true=any non-zero value
. Wtedy clang prawdopodobnie nie spowodowałby awarii kodu dla tego konkretnego wystąpienia UB. (Ale nadal byłoby to dozwolone, gdyby chciał). Nie znam żadnych implementacji, które wybierają coś innego, co robi dla x86-64 bool
, ale standard C ++ pozwala na wiele rzeczy, których nikt nie chce, a nawet nie chciałby robić sprzęt podobny do obecnych procesorów.
ISO C ++ pozostawia nieokreślone, co znajdziesz podczas badania lub modyfikacji reprezentacji obiektowejbool
. (np. poprzez memcpy
wpisanie bool
do unsigned char
, co możesz zrobić, ponieważ char*
może alias cokolwiek. Iunsigned char
jest gwarantowana nie mieć bity wypełniające, więc C ++ standardowy sposób formalnie pozwalają HexDump reprezentacje obiektów bez UB. Pointer odlewania skopiować obiekt reprezentacja różni się char foo = my_bool
oczywiście od przypisywania , więc booleanizacja na 0 lub 1 nie miałaby miejsca, a otrzymalibyśmy reprezentację surowego obiektu).
Już częściowo „ukryte” UB na tej drodze egzekucji z kompilatora znoinline
. Nawet jeśli nie jest to wbudowane, optymalizacje międzyproceduralne mogą nadal tworzyć wersję funkcji zależną od definicji innej funkcji. (Po pierwsze, clang tworzy plik wykonywalny, a nie uniksową bibliotekę współdzieloną, w której może się zdarzyć interpolacja symboli. Po drugie, definicja w class{}
definicji, więc wszystkie jednostki tłumaczeniowe muszą mieć tę samą definicję. Tak jak w przypadkuinline
słowa kluczowego.)
Tak więc kompilator może emitować po prostu ret
lub ud2
(niedozwoloną instrukcję) jako definicję main
, ponieważ ścieżka wykonania rozpoczynająca się u góry main
nieuchronnie napotyka Niezdefiniowane zachowanie.(Który kompilator może zobaczyć w czasie kompilacji, jeśli zdecyduje się podążać ścieżką przez konstruktor inny niż wbudowany).
Każdy program, który napotka UB, jest całkowicie niezdefiniowany przez całe swoje istnienie. Ale UB wewnątrz funkcji lub if()
gałęzi, która nigdy nie działa, nie uszkadza reszty programu. W praktyce oznacza to, że kompilatory mogą podjąć decyzję o wydaniu niedozwolonej instrukcji lub o ret
czymkolwiek, lub o braku emisji i wpadnięciu do następnego bloku / funkcji, dla całego podstawowego bloku, który można udowodnić w czasie kompilacji, aby zawierał lub prowadził do UB.
GCC i Clang w praktyce nie faktycznie czasami emitować ud2
na UB, a nie próbując nawet do generowania kodu dla ścieżek realizacji, które nie mają sensu. Lub w przypadkach takich jak wypadnięcie końca void
niefunkcji, gcc czasami pomija ret
instrukcję. Jeśli myślałeś, że „moja funkcja po prostu wróci z tym, co zawiera śmieci w RAX”, to jesteś bardzo w błędzie. Nowoczesne kompilatory C ++ nie traktują już języka jako przenośnego języka asemblera. Twój program naprawdę musi być poprawnym językiem C ++, nie przyjmując założeń o tym, jak autonomiczna, nie wstawiona wersja twojej funkcji może wyglądać w asm.
Innym zabawnym przykładem jest to, dlaczego niewyrównany dostęp do pamięci mmap czasami nie działa poprawnie na AMD64? . x86 nie ma błędu w niezaangażowanych liczbach całkowitych, prawda? Dlaczego więc źle ustawiony uint16_t*
byłby problem? Ponieważ alignof(uint16_t) == 2
i naruszenie tego założenia doprowadziło do segfaulta podczas auto-wektoryzacji za pomocą SSE2.
Zobacz także Co każdy programista C powinien wiedzieć o nieokreślonym zachowaniu # 1/3 , artykuł autora clang.
Kluczowy punkt: jeśli kompilator zauważy UB w czasie kompilacji, może „złamać” (emitować zaskakujący asm) ścieżkę przez twój kod, który powoduje UB, nawet jeśli celuje w ABI, dla którego dowolny wzorzec bitowy jest prawidłową reprezentacją obiektu bool
.
Spodziewaj się całkowitej wrogości wobec wielu błędów popełnianych przez programistę, zwłaszcza rzeczy, o których ostrzegają współczesne kompilatory. Dlatego powinieneś używać -Wall
i naprawiać ostrzeżenia. C ++ nie jest językiem przyjaznym dla użytkownika, a coś w C ++ może być niebezpieczne, nawet jeśli byłoby bezpieczne w asm na celu, dla którego kompilujesz. (np. podpisane przepełnienie to UB w C ++ i kompilatory zakładają, że tak się nie stanie, nawet przy kompilacji dla uzupełnienia x86 2, chyba że użyjeszclang/gcc -fwrapv
).
UB widoczny w czasie kompilacji jest zawsze niebezpieczny i naprawdę trudno jest mieć pewność (z optymalizacją czasu łącza), że naprawdę ukryłeś UB przed kompilatorem, a zatem może uzasadnić rodzaj generowanego asmu.
Nie być zbyt dramatycznym; często kompilatory pozwalają ci uciec od pewnych rzeczy i emitują kod tak, jak tego oczekujesz, nawet jeśli coś jest UB. Ale może będzie to problem w przyszłości, jeśli twórcy kompilatora wdrożą jakąś optymalizację, która uzyska więcej informacji o zakresach wartości (np. Że zmienna jest nieujemna, może umożliwiając optymalizację rozszerzenia znaku do wolnego rozszerzenia zerowego na x86- 64). Na przykład w bieżącym gcc i clang robienie tmp = a+INT_MIN
nie jest optymalizowane a<0
jako zawsze-fałsz, tylko tmp
to zawsze jest ujemne. (Ponieważ INT_MIN
+ a=INT_MAX
jest ujemny względem celu uzupełnienia 2, ia
nie może być wyższy niż to.)
Więc gcc / clang nie wycofuje się obecnie w celu uzyskania informacji o zakresie dla danych wejściowych obliczeń, tylko na podstawie wyników opartych na założeniu braku podpisanego przepełnienia: przykład na Godbolt . Nie wiem, czy taka optymalizacja jest celowo „pomijana” w imię przyjazności dla użytkownika czy co.
Zauważ również, że implementacje (znane również jako kompilatory) mogą definiować zachowanie, które ISO C ++ pozostawia niezdefiniowane . Na przykład wszystkie kompilatory obsługujące elementy wewnętrzne Intela (takie jak _mm_add_ps(__m128, __m128)
ręczna wektoryzacja SIMD) muszą zezwalać na tworzenie źle wyrównanych wskaźników, które są UB w C ++, nawet jeśli się ich nie lekceważy. __m128i _mm_loadu_si128(const __m128i *)
wykonuje wyrównane obciążenia, przyjmując źle wyrównany __m128i*
argument, a nie a void*
lub char*
. Czy `reinterpret_cast`ing między sprzętowym wskaźnikiem wektorowym a odpowiednim typem jest niezdefiniowanym zachowaniem?
GNU C / C ++ definiuje również zachowanie przesunięcia w lewo liczby ujemnej ze znakiem (nawet bez -fwrapv
), niezależnie od normalnych reguł UB z przepełnieniem ze znakiem. ( Jest to UB w ISO C ++ , podczas gdy odpowiednie przesunięcia liczb podpisanych są zdefiniowane w implementacji (logiczne vs. arytmetyka); implementacje dobrej jakości wybierają arytmetykę w HW, która ma arytmetyczne przesunięcia w prawo, ale ISO C ++ nie określa). Jest to udokumentowane w części całkowitej instrukcji GCC , wraz ze zdefiniowaniem zachowania zdefiniowanego w implementacji, że standardy C wymagają implementacji do zdefiniowania w taki czy inny sposób.
Zdecydowanie istnieją problemy z jakością implementacji, którymi interesują się twórcy kompilatorów; generalnie nie próbują tworzyć kompilatorów, które są celowo wrogie, ale wykorzystując wszystkie dziury UB w C ++ (z wyjątkiem tych, które zdecydują się zdefiniować) w celu lepszej optymalizacji, czasami mogą być prawie nie do odróżnienia.
Przypis 1 : Górne 56 bitów może być śmieciami, które callee musi zignorować, jak zwykle dla typów węższych niż rejestr.
( Inne Abis zrobić dokonać różnych wyborów tutaj . Niektóre wymagają wąskich typów całkowitych być zerowa lub logowania rozszerzony wypełnić rejestr gdy przekazywane lub powrocie z funkcji, takich jak MIPS64 i PowerPC64. Zobacz ostatni odcinek tej x86-64 odpowiedź który porównuje się z wcześniejszymi wersjami ISA ).
Na przykład osoba dzwoniąca mogła obliczyć a & 0x01010101
w RDI i użyć jej do czegoś innego, zanim zadzwoni bool_func(a&1)
. Osoba dzwoniąca może zoptymalizować, &1
ponieważ już to zrobiła dla niskiego bajtu jako część and edi, 0x01010101
i wie, że odbiorca jest zobowiązany do zignorowania wysokich bajtów.
Lub jeśli bool jest przekazywany jako trzeci argument, być może program wywołujący optymalizujący rozmiar kodu ładuje go mov dl, [mem]
zamiast movzx edx, [mem]
, oszczędzając 1 bajt kosztem fałszywej zależności od starej wartości RDX (lub innego efektu częściowego rejestru, w zależności w modelu procesora). Lub dla pierwszego argumentu mov dil, byte [r10]
zamiast movzx edi, byte [r10]
, ponieważ oba wymagają i tak prefiksu REX.
To dlatego dzyń wydzielające movzx eax, dil
w Serialize
, zamiast sub eax, edi
. (W przypadku argumentów liczb całkowitych clang narusza tę zasadę ABI, zamiast tego w zależności od nieudokumentowanego zachowania gcc i clang do zerowych lub rozszerzających wąskie liczby całkowite do 32 bitów. Czy rozszerzenie znaku lub zera jest wymagane przy dodawaniu 32-bitowego przesunięcia do wskaźnika dla x86-64 ABI?
Byłem więc zainteresowany, aby zobaczyć, że to nie robi tego samego bool
.)
Przypis 2: Po rozgałęzieniu masz po prostu 4-bajtowy- mov
natychmiastowy lub 4-bajtowy + 1-bajtowy sklep. Długość jest niejawna w szerokości sklepu + przesunięciach.
OTOH, glibc memcpy wykona dwa 4-bajtowe ładunki / sklepy z nakładaniem się, które zależy od długości, więc tak naprawdę kończy się to uwolnieniem całości od warunkowych gałęzi na boolean. Zobacz L(between_4_7):
blok w memcpy / memmove glibc. Lub przynajmniej, idź tą samą drogą dla jednego z wartości logicznych w gałęzi memcpy, aby wybrać wielkość porcji.
W przypadku mov
wstawiania można użyć 2x -immediate + cmov
i warunkowego przesunięcia lub pozostawić ciąg danych w pamięci.
Lub jeśli dostroisz się do Intel Ice Lake ( z funkcją Fast Short REP MOV ), rzeczywisty rep movsb
może być optymalny. glibc memcpy
może zacząć używać rep movsb
do małych procesorów z tą funkcją, oszczędzając wiele rozgałęzień.
Narzędzia do wykrywania UB i użycia niezainicjowanych wartości
W gcc i clang można skompilować, -fsanitize=undefined
aby dodać instrumentację w czasie wykonywania, która będzie ostrzegać lub wykasować błąd na UB, który zdarza się w czasie wykonywania. Nie złapie to jednak zmiennych jednostkowych. (Ponieważ nie zwiększa rozmiarów czcionek, aby zrobić miejsce na „niezainicjowany” bit).
Zobacz https://developers.redhat.com/blog/2014/10/16/gcc-undefined-behavior-sanitizer-ubsan/
Aby znaleźć użycie niezainicjowanych danych, w Clang / LLVM znajduje się Sanitizer adresu i Sanitizer pamięci. https://github.com/google/sanitizers/wiki/MemorySanitizer pokazuje przykłady clang -fsanitize=memory -fPIE -pie
wykrywania niezainicjowanych odczytów pamięci. Może działać najlepiej, jeśli kompilujesz bez optymalizacji, więc wszystkie odczyty zmiennych w rzeczywistości ładują się z pamięci w asm. Pokazują, że jest używany -O2
w przypadku, gdy obciążenie nie zoptymalizuje się. Sam tego nie próbowałem. (W niektórych przypadkach, np. Nie inicjując akumulatora przed zsumowaniem tablicy, brzęk -O3 wyśle kod sumujący się do rejestru wektorowego, którego nigdy nie zainicjował. Tak więc dzięki optymalizacji możesz mieć przypadek, w którym nie ma odczytu pamięci powiązanego z UB . Ale-fsanitize=memory
zmienia wygenerowany asm i może spowodować sprawdzenie tego.)
Toleruje kopiowanie niezainicjowanej pamięci, a także proste operacje logiczne i arytmetyczne z nią. Ogólnie MemorySanitizer cicho śledzi rozprzestrzenianie się niezainicjowanych danych w pamięci i zgłasza ostrzeżenie, gdy gałąź kodu jest pobierana (lub nie jest pobierana) w zależności od niezainicjowanej wartości.
MemorySanitizer implementuje podzbiór funkcjonalności znaleziony w Valgrind (narzędzie Memcheck).
Powinno to działać w tym przypadku, ponieważ wywołanie glibc memcpy
z length
obliczeniem z niezainicjowanej pamięci spowoduje (wewnątrz biblioteki) utworzenie gałęzi opartej na length
. Gdyby wprowadził w pełni bez rozgałęzioną wersję, która właśnie używała cmov
, indeksował i dwa sklepy, mógł nie działać.
Valgrindmemcheck
będzie również szukał tego rodzaju problemu, ponownie nie narzekając, jeśli program po prostu kopiuje niezainicjowane dane. Mówi jednak, że wykryje, kiedy „Skok warunkowy lub ruch zależy od niezainicjowanych wartości (wartości)”, aby spróbować uchwycić wszelkie widoczne z zewnątrz zachowanie, które zależy od niezainicjowanych danych.
Być może idea nie oznaczania tylko obciążenia polega na tym, że struktury mogą mieć dopełnianie, a kopiowanie całej struktury (w tym wypełniania) za pomocą szerokiego wczytywania / przechowywania wektorów nie jest błędem, nawet jeśli poszczególne elementy były pisane tylko pojedynczo. Na poziomie asm utracono informacje o tym, co było dopełnieniem i co faktycznie stanowi część wartości.