Czy standard C ++ pozwala niezainicjowanemu boolowi na zawieszenie programu?


500

Wiem, że „niezdefiniowane zachowanie” w C ++ może pozwolić kompilatorowi na zrobienie wszystkiego, co chce. Miałem jednak awarię, która mnie zaskoczyła, ponieważ uznałem, że kod jest wystarczająco bezpieczny.

W takim przypadku prawdziwy problem wystąpił tylko na konkretnej platformie używającej określonego kompilatora i tylko wtedy, gdy włączono optymalizację.

Próbowałem kilku rzeczy, aby odtworzyć problem i maksymalnie go uprościć. Oto fragment funkcji o nazwie Serialize, która pobierałaby parametr bool i kopiowałaby ciąg truelub falsedo istniejącego bufora docelowego.

Czy ta funkcja byłaby w przeglądzie kodu, nie byłoby sposobu, aby powiedzieć, że w rzeczywistości mogłaby się zawiesić, gdyby parametr bool był wartością niezainicjowaną?

// 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);
}

Jeśli ten kod jest wykonywany z optymalizacjami clang 5.0.0 +, to może / może ulec awarii.

Oczekiwany operator trójskładnikowy boolValue ? "true" : "false"wyglądał na wystarczająco bezpieczny dla mnie, zakładałem: „Niezależnie od tego, jaką wartość ma śmieci, boolValuenie ma to znaczenia, ponieważ i tak będzie to prawda lub fałsz”.

Mam skonfigurowany przykład Eksploratora kompilatora, który pokazuje problem podczas demontażu, tutaj pełny przykład. Uwaga: w celu zlikwidowania problemu kombinacja, którą znalazłem, działała, używając Clang 5.0.0 z optymalizacją -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;
}

Problem powstaje z powodu optymalizatora: był wystarczająco sprytny, aby wywnioskować, że ciągi „prawda” i „fałsz” różnią się tylko długością o 1. Więc zamiast naprawdę obliczać długość, używa wartości samego bool, który powinien technicznie wynosi 0 lub 1 i wygląda następująco:

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

Chociaż jest to „sprytne”, że tak powiem, moje pytanie brzmi: czy standard C ++ pozwala kompilatorowi założyć, że bool może mieć tylko wewnętrzną reprezentację liczbową „0” lub „1” i używać go w taki sposób?

Czy jest to przypadek zdefiniowany w implementacji, w którym to przypadku implementacja przyjęła, że ​​wszystkie jej boole będą zawierały zawsze 0 lub 1, a każda inna wartość jest terytorium niezdefiniowanym?


200
To świetne pytanie. Jest to solidna ilustracja tego, jak niezdefiniowane zachowanie nie jest tylko teoretyczną kwestią. Kiedy ludzie mówią, że wszystko może się zdarzyć w wyniku UB, to „wszystko” może naprawdę być dość zaskakujące. Można założyć, że niezdefiniowane zachowanie wciąż przejawia się w przewidywalny sposób, ale w dzisiejszych czasach przy nowoczesnych optymalizatorach to wcale nie jest prawda. OP poświęcił czas na stworzenie MCVE, dokładnie zbadał problem, sprawdził demontaż i zadał jasne, proste pytanie na jego temat. Nie mogłem prosić o więcej.
John Kugelman,

7
Zauważ, że wymóg, że „niezerowa wartość ewaluuje true” jest regułą dotyczącą operacji boolowskich, w tym „przypisania do bool” (która może domyślnie wywoływać static_cast<bool>()zależne od specyfiki). Nie jest to jednak wymóg dotyczący wewnętrznej reprezentacji boolwybranego przez kompilator.
Euro Micelli,

2
Komentarze nie są przeznaczone do rozszerzonej dyskusji; ta rozmowa została przeniesiona do czatu .
Samuel Liew

3
Mówiąc bardzo pokrewnie, jest to „zabawne” źródło niezgodności binarnej. Jeśli masz ABI A, który zeruje wartości przed wywołaniem funkcji, ale kompiluje funkcje w taki sposób, że zakłada, że ​​parametry są zerowane, a ABI B jest odwrotny (nie zeruje, ale nie przyjmuje zera -podstawione parametry), to będzie w większości działać, ale funkcja korzystająca z B ABI spowoduje problemy, jeśli wywoła funkcję za pomocą A ABI, który przyjmuje „mały” parametr. IIRC masz to na x86 z clang i ICC.
TLW

1
@TLW: Chociaż standard nie wymaga, aby implementacje zapewniały jakikolwiek sposób wywoływania lub wywoływania przez kod zewnętrzny, pomocne byłoby posiadanie sposobu określania takich rzeczy dla implementacji tam, gdzie są one istotne (implementacje, w których takie szczegóły nie są odpowiednie może zignorować takie atrybuty).
supercat

Odpowiedzi:


285

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 booljako argument arg w rejestrze jest reprezentowany przez wzorce bitowe false=0itrue=1 w 8 niskich bitach rejestru 1 . W pamięci booljest 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 booldla dowolnej architektury (nie tylko x86). Pozwala na optymalizacje typu !myboolz xor eax,1odwracaniem 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&&bdo bitowego AND dla booltypó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 memcpyjako 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 mainz 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 mainjest wartością go stosuje uninitializedBool. Właśnie dlatego masz wartości, które nie były tylko 0.

5U - random garbagemoż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=0i 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 memcpywpisanie booldo 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_booloczywiś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 retlub ud2(niedozwoloną instrukcję) jako definicję main, ponieważ ścieżka wykonania rozpoczynająca się u góry mainnieuchronnie 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 retczymkolwiek, 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ć ud2na 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 voidniefunkcji, gcc czasami pomija retinstrukcję. 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) == 2i 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ć -Walli 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_MINnie jest optymalizowane a<0jako zawsze-fałsz, tylko tmpto zawsze jest ujemne. (Ponieważ INT_MIN+ a=INT_MAXjest 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 & 0x01010101w RDI i użyć jej do czegoś innego, zanim zadzwoni bool_func(a&1). Osoba dzwoniąca może zoptymalizować, &1ponieważ już to zrobiła dla niskiego bajtu jako część and edi, 0x01010101i 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, dilw 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- movnatychmiastowy 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 movwstawiania można użyć 2x -immediate + cmovi 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 movsbmoże być optymalny. glibc memcpymoż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=undefinedaby 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 -piewykrywania 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 -O2w 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 memcpyz lengthobliczeniem 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.


2
Widziałem gorszy przypadek, w którym zmienna przyjęła wartość nie w zakresie 8-bitowej liczby całkowitej, ale tylko całego rejestru procesora. A Itanium ma jeszcze gorzej, użycie niezainicjowanej zmiennej może natychmiast ulec awarii.
Joshua

2
@Joshua: och, racja, dobra uwaga, wyraźna spekulacja Itanium oznaczy wartości rejestru odpowiednikiem „nie liczby”, na przykład za pomocą błędów wartości.
Peter Cordes,

11
Co więcej, ilustruje to również, dlaczego przede wszystkim wprowadzono funkcję UB w projektowaniu języków C i C ++: ponieważ daje to kompilatorowi dokładnie taką swobodę, która pozwoliła teraz najnowocześniejszym kompilatorom wykonywać te wysokiej jakości optymalizacje, dzięki którym C / C ++ jest tak wydajnym językiem średniego poziomu.
The_Sympathizer

2
I tak trwa wojna między twórcami kompilatorów C ++ a programistami C ++ próbującymi pisać użyteczne programy. Ta odpowiedź, całkowicie wyczerpująca w odpowiedzi na to pytanie, może być również wykorzystana jako przekonująca kopia reklamy dla dostawców narzędzi do analizy statycznej ...
davidbak

4
@The_Sympathizer: UB został uwzględniony, aby umożliwić implementacjom zachowanie się w sposób, który byłby najbardziej użyteczny dla ich klientów . Nie miało to sugerować, że wszystkie zachowania należy uznać za równie przydatne.
supercat

56

Kompilator może założyć, że wartość logiczna przekazana jako argument jest prawidłową wartością logiczną (tj. Taką, która została zainicjowana lub przekonwertowana na truelub false). trueWartość nie musi być taka sama, jak liczba całkowita 1 - Rzeczywiście, nie mogą być różne reprezentacje truei false- ale parametr musi być jakiś ważny przedstawienie jednej z tych dwóch wartości, gdzie „ważne reprezentacja” to implementation- zdefiniowane.

Jeśli więc nie uda się zainicjować a boollub jeśli uda się go nadpisać jakimś wskaźnikiem innego typu, wówczas założenia kompilatora będą błędne i nastąpi niezdefiniowane zachowanie. Zostałeś ostrzeżony:

50) Używanie wartości bool w sposób opisany w niniejszej Normie Międzynarodowej jako „niezdefiniowany”, na przykład poprzez badanie wartości niezainicjowanego obiektu automatycznego, może spowodować, że będzie on zachowywał się tak, jakby nie był ani prawdziwy, ani fałszywy. (Przypis do pkt 6 §6.9.1, Typy podstawowe)


11
trueWartość nie musi być taka sama jak liczba całkowita 1” jest niejasnym wprowadzeniem w błąd. Jasne, rzeczywisty wzorzec bitowy może być czymś innym, ale gdy domyślnie jest konwertowany / promowany (jedyny sposób, w jaki można zobaczyć wartość inną niż true/ false), truejest zawsze 1i falsezawsze jest0 . Oczywiście, taki kompilator nie byłby również w stanie skorzystać ze sztuczki, której ten kompilator próbował użyć (biorąc pod uwagę fakt, że boolfaktyczny wzorzec bitów może być tylko 0lub 1), więc jest to trochę nieistotne dla problemu OP.
ShadowRanger

4
@ShadowRanger Zawsze możesz bezpośrednio sprawdzić reprezentację obiektu.
TC

7
@shadowranger: Chodzi mi o to, że wdrożenie jest odpowiedzialne. Jeśli ogranicza prawidłowe reprezentacje truedo wzorca bitowego 1, jest to jego przywilej. Jeśli wybierze jakiś inny zestaw reprezentacji, to rzeczywiście nie będzie mógł skorzystać z opisanej tutaj optymalizacji. Jeśli wybierze tę konkretną reprezentację, może to zrobić. Musi być tylko wewnętrznie spójny. Państwo może zbadać reprezentacji boolkopiując go do tablicy bajtów; to nie jest UB (ale jest zdefiniowane w implementacji)
rici,

3
Tak, optymalizacja kompilatorów (tj. Rzeczywista implementacja C ++) często emituje kod, który zależy od boolwzorca bitowego 0lub 1. Nie ponawiają booleanizacji za boolkażdym razem, gdy czytają go z pamięci (lub rejestru zawierającego funkcję arg). Tak mówi ta odpowiedź. przykłady : gcc4.7 + może zoptymalizować return a||bdo or eax, edizwracanej funkcji boollub MSVC może zoptymalizować a&bdo test cl, dl. x86 testjest trochę bitowe and , więc jeśli cl=1i dl=2test ustawi flagi zgodnie z cl&dl = 0.
Peter Cordes,

5
Chodzi o to, że zachowanie nieokreślone polega na tym, że kompilator może wyciągać z niego o wiele więcej wniosków, np. Zakładać, że ścieżka kodu, która prowadziłaby do uzyskania dostępu do niezainicjowanej wartości, nigdy nie jest brana pod uwagę, ponieważ zapewnienie, że to właśnie odpowiedzialność programisty . Nie chodzi więc tylko o możliwość, że wartości niskiego poziomu mogą być różne od zera lub jednego.
Holger,

52

Sama funkcja jest poprawna, ale w programie testowym instrukcja, która wywołuje funkcję, powoduje niezdefiniowane zachowanie przy użyciu wartości niezainicjowanej zmiennej.

Błąd występuje w funkcji wywołującej i można go wykryć przez przegląd kodu lub analizę statyczną funkcji wywołującej. Za pomocą linku eksploratora kompilatora kompilator gcc 8.2 wykrywa błąd. (Może mógłbyś zgłosić raport o błędzie przeciwko brzęczeniu, że nie znajduje on problemu).

Niezdefiniowane zachowanie oznacza, że wszystko może się zdarzyć, w tym awaria programu kilka linii po zdarzeniu, które wywołało niezdefiniowane zachowanie.

NB Odpowiedź na „Czy niezdefiniowane zachowanie może powodować _____?” jest zawsze „Tak”. To dosłownie definicja niezdefiniowanego zachowania.


2
Czy pierwsza klauzula jest prawdziwa? Czy samo kopiowanie niezainicjowanego boolwyzwalacza UB?
Joshua Green

10
@JoshuaGreen patrz [dcl.init] / 12 „Jeżeli nieokreślona wartość jest generowana przez ocenę, zachowanie jest niezdefiniowane, z wyjątkiem następujących przypadków:” (i żaden z tych przypadków nie ma wyjątku bool). Kopiowanie wymaga oceny źródła
MM

8
@JoshuaGreen A powodem tego jest to, że możesz mieć platformę, która wyzwala błąd sprzętowy, jeśli uzyskasz dostęp do niektórych nieprawidłowych wartości dla niektórych typów. Są to czasami nazywane „reprezentacjami pułapek”.
David Schwartz

7
Itanium, choć niejasny, jest wciąż produkowanym procesorem, ma pułapki i ma co najmniej dwa pół-nowoczesne kompilatory C ++ (Intel / HP). To dosłownie ma true, falsei not-a-thingwartości logicznych.
MSalters

3
Z drugiej strony, odpowiedź na „Czy standard wymaga, aby wszystkie kompilatory przetwarzały coś w określony sposób” brzmi „nie”, nawet / szczególnie w przypadkach, gdy oczywiste jest, że powinien to zrobić każdy kompilator wysokiej jakości; im bardziej oczywiste jest to, że autorzy Standardu nie powinni już tak mówić.
supercat

23

Bool może przechowywać tylko wartości zależne od implementacji używane wewnętrznie dla truei false, a wygenerowany kod może założyć, że będzie przechowywał tylko jedną z tych dwóch wartości.

Zazwyczaj implementacja użyje liczby całkowitej 0dla falsei 1dla true, aby uprościć konwersje między booli int, i if (boolvar)wygenerować taki sam kod jak if (intvar). W takim przypadku można sobie wyobrazić, że kod wygenerowany dla trójki w przydziale użyłby tej wartości jako indeksu w tablicy wskaźników do dwóch łańcuchów, tzn. Mógłby zostać przekonwertowany na coś takiego:

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

Jeśli nie boolValuejest zainicjowany, może w rzeczywistości zawierać dowolną wartość całkowitą, co może spowodować dostęp poza granice stringstablicy.


1
@SidS Thanks. Teoretycznie reprezentacje wewnętrzne mogą być przeciwieństwem sposobu, w jaki rzutują na liczby całkowite / z liczb całkowitych, ale byłoby to przewrotne.
Barmar

1
Masz rację, a twój przykład również się zawiesi. Jest to jednak „widoczne” dla recenzji kodu, że używasz niezainicjowanej zmiennej jako indeksu tablicy. Ponadto zawiesiłby się nawet podczas debugowania (na przykład niektóre debugery / kompilatory zainicjują się z określonymi wzorami, aby łatwiej było zobaczyć, kiedy ulega awarii). W moim przykładzie zaskakujące jest to, że użycie bool jest niewidoczne: optymalizator postanowił użyć go w obliczeniach nieobecnych w kodzie źródłowym.
Remz

3
@ Remem Po prostu używam tablicy, aby pokazać, co wygenerowany kod może być równoważny, nie sugerując, że ktokolwiek mógłby to napisać.
Barmar

1
@Remz przekształcenie boolsię intze *(int *)&boolValuei wydrukować go do celów debugowania, sprawdzić, czy jest coś innego niż 0lub 1kiedy się zawiesi. Jeśli tak jest, to prawie potwierdza teorię, że kompilator optymalizuje wbudowany - jeśli jako tablicę, co wyjaśnia, dlaczego ulega awarii.
Havenard,

2
@MSalters: std::bitset<8>nie nadaje mi ładnych nazw dla wszystkich moich różnych flag. W zależności od tego, jakie są, może to być ważne.
Martin Bonner wspiera Monikę

15

Często podsumowując pytanie, zadajesz pytanie Czy standard C ++ pozwala kompilatorowi założyć, że boolmoże mieć tylko wewnętrzną reprezentację liczbową „0” lub „1” i używać go w taki sposób?

Standard nie mówi nic o wewnętrznej reprezentacji bool. Określa tylko, co się dzieje, gdy rzutujesz „ boolna” int(lub odwrotnie). Przeważnie, z powodu tych integralnych konwersji (i faktu, że ludzie polegają na nich dość mocno), kompilator użyje 0 i 1, ale nie musi (chociaż musi przestrzegać ograniczeń dowolnego używanego ABI niższego poziomu ).

Tak więc kompilator, gdy widzi a, boolma prawo uznać, że wspomniany boolzawiera jeden ze wzorów bitowych „ true” lub „ false” i zrobić wszystko, co mu się podoba. Więc jeśli wartości truei falsesą 1 i 0, odpowiednio, kompilator jest rzeczywiście pozwolił, aby zoptymalizować strlendo 5 - <boolean value>. Możliwe są inne zabawne zachowania!

Jak wielokrotnie tu powtarzano, niezdefiniowane zachowanie ma niezdefiniowane wyniki. Zawierające Ale nie ograniczone do

  • Twój kod działa zgodnie z oczekiwaniami
  • Twój kod zawodzi losowo
  • Twój kod nie jest w ogóle uruchamiany.

Zobacz Co każdy programista powinien wiedzieć o nieokreślonym zachowaniu

Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.