Dlaczego printf („% f”, 0); dać niezdefiniowane zachowanie?

87

Wyrok

printf("%f\n",0.0f);

drukuje 0.

Jednak oświadczenie

printf("%f\n",0);

wypisuje losowe wartości.

Zdaję sobie sprawę, że przejawiam jakieś niezdefiniowane zachowanie, ale nie potrafię dokładnie określić dlaczego.

Wartość zmiennoprzecinkowa, w której wszystkie bity są równe 0, jest nadal ważna i floatma wartość 0.
floati intmają ten sam rozmiar na moim komputerze (jeśli to nawet ma znaczenie).

Dlaczego użycie literału liczby całkowitej zamiast literału zmiennoprzecinkowego printfpowoduje takie zachowanie?

PS to samo zachowanie można zobaczyć, jeśli używam

int i = 0;
printf("%f\n", i);
Trevor Hickey
źródło
37
printfoczekuje a double, a ty dajesz mu int. floati intmoże mieć ten sam rozmiar na twoim komputerze, ale w 0.0frzeczywistości jest konwertowany na a doublepo umieszczeniu na wariadycznej liście argumentów (i printfoczekuje tego). Krótko mówiąc, nie wywiązujesz się ze swojej części umowy printfna podstawie specyfikacji, których używasz, i argumentów, które podajesz.
WhozCraig
22
Funkcje-Varargsa nie konwertują automatycznie argumentów funkcji na typ odpowiedniego parametru, ponieważ nie mogą. Niezbędne informacje nie są dostępne dla kompilatora, w przeciwieństwie do funkcji innych niż varargs z prototypem.
EOF
3
Oooh ... "wariadyka". Właśnie nauczyłem się nowego słowa ...
Mike Robinson
3
Następną rzeczą jest, aby spróbować przekazać (uint64_t)0zamiast 0i zobaczyć, czy nadal się losowo (przy założeniu zachowania doublei uint64_tmają tę samą wielkość i wyrównanie). Istnieje prawdopodobieństwo, że dane wyjściowe będą nadal losowe na niektórych platformach (np. X86_64) ze względu na różne typy przesyłane w różnych rejestrach.
Ian Abbott

Odpowiedzi:

121

"%f"Format wymaga argumentu typu double. Dajesz mu argument typu int. Dlatego zachowanie jest nieokreślone.

Standard nie gwarantuje, że wszystkie bity-zero są poprawną reprezentacją 0.0(chociaż często tak jest) lub jakiejkolwiek doublewartości, inti doublemają ten sam rozmiar (pamiętaj, że doublenie float), lub nawet jeśli są takie same size, że są przekazywane jako argumenty do funkcji wariadycznej w ten sam sposób.

Może się zdarzyć, że „zadziała” w Twoim systemie. To najgorszy możliwy objaw niezdefiniowanego zachowania, ponieważ utrudnia zdiagnozowanie błędu.

N1570 7.21.6.1 akapit 9:

... Jeśli którykolwiek argument nie jest prawidłowym typem dla odpowiedniej specyfikacji konwersji, zachowanie jest niezdefiniowane.

Argumenty typu floatsą promowane double, dlatego printf("%f\n",0.0f)działa. Argumenty typów całkowitych węższe niż intsą promowane do intlub do unsigned int. Niniejsze zasady promocji (określone w N1570 6.5.2.2 paragraf 6) nie pomagają w przypadku printf("%f\n", 0).

Zwróć uwagę, że jeśli przekazujesz stałą 0do niezmiennej funkcji, która oczekuje doubleargumentu, zachowanie jest dobrze zdefiniowane, zakładając, że prototyp funkcji jest widoczny. Na przykład sqrt(0)(po #include <math.h>) niejawnie konwertuje argument 0z intna double- ponieważ kompilator może zobaczyć na podstawie deklaracji sqrt, że oczekuje doubleargumentu. Nie ma takich informacji dla printf. Funkcje zróżnicowane, takie jak, printfsą specjalne i wymagają większej uwagi podczas pisania do nich wywołań.

Keith Thompson
źródło
13
Kilka doskonałych punktów podstawowych. Po pierwsze, że to doublenie floattak szerokość założenie PO może nie (prawdopodobnie nie robi) zawieszone. Po drugie, założenie, że zero całkowite i zero zmiennoprzecinkowe mają ten sam wzór bitowy, również nie jest aktualne. Dobra robota
Lightness Races in Orbit
2
@LucasTrzesniewski: Ok, ale nie rozumiem, jak moja odpowiedź nasuwa pytanie. Stwierdziłem, że floatjest to promowane doublebez wyjaśnienia, dlaczego, ale nie o to chodziło.
Keith Thompson
2
@ robertbristow-johnson: Kompilatory nie muszą mieć specjalnych punktów zaczepienia printf, chociaż na przykład gcc je ma, więc może diagnozować błędy ( jeśli ciąg formatu jest literałem). Kompilator może zobaczyć deklarację printffrom <stdio.h>, która mówi mu, że pierwszy parametr to a, const char*a pozostałe są wskazywane przez , .... Nie, %fjest dla double(i floatjest promowany double) i %lfjest dla long double. Standard C nie mówi nic o stosie. Określa zachowanie printftylko wtedy, gdy jest wywoływana poprawnie.
Keith Thompson
2
@ robertbristow-johnson: W dawnym oszołomieniu, "lint" często wykonywał dodatkowe sprawdzanie, które teraz wykonuje gcc. floatPrzekazywane printfjest podnoszony do double; nie ma w tym nic magicznego, to tylko reguła języka dotycząca wywoływania funkcji wariadycznych. printfsama wie poprzez łańcuch formatu, co wywołujący twierdzi, że ma do niego przekazać; jeśli to twierdzenie jest nieprawidłowe, zachowanie jest nieokreślone.
Keith Thompson,
2
Mała korekta: the lmodyfikator długość „nie ma wpływu na kolejnym a, A, e, E, f, F, g, lub Gspecyfikacją konwersji”, modyfikator długości do long doublekonwersji jest L. (@ robertbristow-johnson może być również zainteresowany)
Daniel Fischer
58

Po pierwsze, jak poruszył w kilku innych odpowiedzi, ale nie, moim zdaniem, wypisanym wystarczająco jasno: to robi pracę, aby zapewnić całkowitą w większości kontekstów gdzie funkcja biblioteki zajmuje doublelub floatargument. Kompilator automatycznie wstawi konwersję. Na przykład, sqrt(0)jest dobrze zdefiniowany i będzie się zachowywał dokładnie tak sqrt((double)0), jak i to samo dotyczy każdego innego wyrażenia typu całkowitego tam użytego.

printfjest inny. Jest inaczej, ponieważ wymaga zmiennej liczby argumentów. Jego prototypem funkcji jest

extern int printf(const char *fmt, ...);

Dlatego kiedy piszesz

printf(message, 0);

kompilator nie ma żadnych informacji o tym, jaki typ printf oczekuje tego drugiego argumentu. Ma tylko typ wyrażenia argumentu, którym jest int. Dlatego, w przeciwieństwie do większości funkcji bibliotecznych, to ty, programista, musisz upewnić się, że lista argumentów jest zgodna z oczekiwaniami ciągu formatu.

(Nowoczesne kompilatory mogą zajrzeć do ciągu formatu i powiedzieć, że masz niezgodność typów, ale nie zaczną wstawiać konwersji, aby osiągnąć to, co miałeś na myśli, ponieważ lepszy kod powinien się teraz zepsuć, kiedy zauważysz , niż lata później, gdy został przebudowany za pomocą mniej pomocnego kompilatora).

Teraz druga połowa pytania brzmiała: biorąc pod uwagę, że (int) 0 i (float) 0.0 są, w większości nowoczesnych systemów, reprezentowane jako 32 bity, z których wszystkie są zerowe, dlaczego i tak nie działa to przypadkowo? Standard C mówi tylko, że „to nie jest wymagane do pracy, jesteś sam”, ale pozwól mi przeliterować dwa najczęstsze powody, dla których to nie zadziała; to prawdopodobnie pomoże ci zrozumieć, dlaczego nie jest to wymagane.

Po pierwsze, z powodów historycznych, kiedy przechodzisz floatprzez zmienną listę argumentów, jest ona promowana do double, która w większości nowoczesnych systemów ma 64 bity. Zatem printf("%f", 0)przekazuje tylko 32 bity zerowe do wywoływanego, oczekując 64 z nich.

Drugim, równie istotnym powodem jest to, że argumenty funkcji zmiennoprzecinkowych mogą być przekazywane w innym miejscu niż argumenty liczb całkowitych. Na przykład większość procesorów ma oddzielne pliki rejestrów dla liczb całkowitych i wartości zmiennoprzecinkowych, więc może być regułą, że argumenty od 0 do 4 trafiają do rejestrów od r0 do r4, jeśli są liczbami całkowitymi, ale od f0 do f4, jeśli są zmiennoprzecinkowe. Więc printf("%f", 0)szuka w rejestrze f1 tego zera, ale w ogóle go tam nie ma.

zwol
źródło
1
Czy istnieją architektury, które używają rejestrów dla funkcji wariadycznych, nawet wśród tych, które używają ich do normalnych funkcji? Pomyślałem, że to jest powód, dla którego funkcje wariadyczne muszą być poprawnie zadeklarowane, mimo że inne funkcje [z wyjątkiem tych z argumentami typu float / short / char] można zadeklarować za pomocą ().
Random832
3
@ Random832 Obecnie jedyną różnicą między konwencją wywoływania wariadycznej a normalną funkcją jest to, że do wariady mogą być dostarczane dodatkowe dane, takie jak liczba dostarczonych argumentów. W przeciwnym razie wszystko dzieje się dokładnie w tym samym miejscu, w jakim byłoby to normalne. Zobacz na przykład sekcję 3.2 x86-64.org/documentation/abi.pdf , gdzie jedynym specjalnym sposobem postępowania w przypadku wariadyków jest przekazana wskazówka AL. (Tak, oznacza to, że implementacja va_argjest znacznie bardziej skomplikowana niż kiedyś.)
zwolnij
@ Random832: Zawsze myślałem, że powodem jest to, że na niektórych architekturach funkcje o znanej liczbie i typie argumentów można zaimplementować wydajniej, używając specjalnych instrukcji.
celtschk
@celtschk Być może myślisz o „oknach rejestru” w SPARC i IA64, które miały przyspieszyć typowy przypadek wywołań funkcji z niewielką liczbą argumentów (niestety w praktyce robią dokładnie odwrotnie). Nie wymagają od kompilatora specjalnego traktowania wywołań funkcji o zmiennej liczbie argumentów, ponieważ liczba argumentów w dowolnym miejscu wywołania jest zawsze stała w czasie kompilacji, niezależnie od tego, czy wywoływana jest zmienna.
zwol
@zwol: Nie, myślałem o ret ninstrukcji 8086, w której nbyła zakodowana na stałe liczba całkowita, która w związku z tym nie miała zastosowania do funkcji wariadycznych. Jednak nie wiem, czy jakikolwiek kompilator C faktycznie to wykorzystał (kompilatory inne niż C z pewnością to zrobiły).
celtschk
13

Zwykle, gdy wywołujesz funkcję, która oczekuje a double, ale podasz an int, kompilator automatycznie przekonwertuje się na a double. Tak się nie dzieje w przypadku printf, ponieważ typy argumentów nie są określone w prototypie funkcji - kompilator nie wie, że należy zastosować konwersję.

Mark Okup
źródło
4
W printf() szczególności jest zaprojektowany tak, aby jego argumenty mogły być dowolnego typu. Musisz wiedzieć, jakiego typu oczekuje każdy element w ciągu formatu i musisz podać go poprawnie.
Mike Robinson
@MikeRobinson: Cóż, każdy prymitywny typ C. To bardzo, bardzo mały podzbiór wszystkich możliwych typów.
MSalters
13

Dlaczego użycie literału liczby całkowitej zamiast literału zmiennoprzecinkowego powoduje takie zachowanie?

Ponieważ printf()nie ma wpisanych parametrów oprócz const char* formatstringpierwszego. Do ...reszty używa się elipsy w stylu c ( ).

Po prostu decyduje, jak interpretować wartości przekazywane tam zgodnie z typami formatowania podanymi w ciągu formatu.

Wystąpiłbyś tak samo niezdefiniowane zachowanie, jak podczas próby

 int i = 0;
 const double* pf = (const double*)(&i);
 printf("%f\n",*pf); // dereferencing the pointer is UB
πάντα ῥεῖ
źródło
3
Niektóre konkretne implementacje printfmogą działać w ten sposób (z wyjątkiem tego, że przekazywane elementy są wartościami, a nie adresami). Standard C nie określa, jak printf i inne funkcje wariadyczne działają, a jedynie określa ich zachowanie. W szczególności nie ma wzmianki o ramkach stosu.
Keith Thompson
Mały quibble: printfma jeden wpisany parametr, ciąg formatu, który jest typem const char*. Przy okazji, pytanie jest oznaczone jako C i C ++, a C jest bardziej odpowiednie; Prawdopodobnie nie użyłbym tego reinterpret_castjako przykładu.
Keith Thompson
Tylko interesująca obserwacja: to samo niezdefiniowane zachowanie i najprawdopodobniej z powodu identycznego mechanizmu, ale z niewielką różnicą w szczegółach: przekazanie int jak w pytaniu, UB dzieje się w printf, gdy próbujesz zinterpretować int jako double - w twoim przykładzie , dzieje się to już na zewnątrz podczas dereferencji pf ...
Aconcagua
@Aconcagua Dodano wyjaśnienie.
πάντα ῥεῖ
Ten przykładowy kod to UB dla ścisłego naruszenia aliasingu, zupełnie inny problem niż to, o co chodzi w pytaniu. Na przykład całkowicie ignorujesz możliwość, że liczby zmiennoprzecinkowe są przekazywane w różnych rejestrach do liczb całkowitych.
MM
12

Użycie źle dopasowanego printf()specyfikatora "%f"i typu (int) 0prowadzi do niezdefiniowanego zachowania.

Jeśli specyfikacja konwersji jest nieprawidłowa, zachowanie jest niezdefiniowane. C11dr §7.21.6.1 9

Kandydujące przyczyny UB.

  1. Jest to UB według specyfikacji, a kompilacja jest skomplikowana - powiedział nuf.

  2. doublei intmają różne rozmiary.

  3. doublei intmogą przekazywać swoje wartości przy użyciu różnych stosów (stos ogólny kontra FPU ).

  4. A double 0.0 może nie być zdefiniowane przez wzorzec wszystkich bitów zerowych. (rzadko spotykany)

chux - Przywróć Monikę
źródło
10

To jedna z tych wspaniałych okazji, aby wyciągnąć wnioski z ostrzeżeń kompilatora.

$ gcc -Wall -Wextra -pedantic fnord.c 
fnord.c: In function ‘main’:
fnord.c:8:2: warning: format ‘%f’ expects argument of type ‘double’, but argument 2 has type ‘int’ [-Wformat=]
  printf("%f\n",0);
  ^

lub

$ clang -Weverything -pedantic fnord.c 
fnord.c:8:16: warning: format specifies type 'double' but the argument has type 'int' [-Wformat]
        printf("%f\n",0);
                ~~    ^
                %d
1 warning generated.

Tak więc printfgeneruje niezdefiniowane zachowanie, ponieważ przekazujesz mu niezgodny typ argumentu.

wyrm
źródło
9

Nie wiem, co jest niejasne.

Twój ciąg formatu oczekuje double; zamiast tego podajesz plikint .

To, czy te dwa typy mają tę samą szerokość bitową, jest całkowicie nieistotne, z wyjątkiem tego, że może to pomóc w uniknięciu wyjątków twardych naruszeń pamięci z powodu takiego zepsutego kodu.

Lekkość wyścigów na orbicie
źródło
3
@Voo: Ten modyfikator ciągu formatu jest niestety nazwany, ale nadal nie rozumiem, dlaczego myślisz, że intbyłby tutaj akceptowalny.
Wyścigi lekkości na orbicie
1
@Voo: "(co również kwalifikowałoby się jako prawidłowy wzorzec zmiennoprzecinkowy)" Dlaczego intkwalifikowałby się jako prawidłowy wzorzec zmiennoprzecinkowy? Uzupełnienie do dwóch i różne kodowania zmiennoprzecinkowe nie mają prawie nic wspólnego.
Wyścigi lekkości na orbicie
2
Jest to mylące, ponieważ w przypadku większości funkcji bibliotecznych podanie literału liczby całkowitej 0do wpisanego argumentu doublezrobi właściwą rzecz. Dla początkującego nie jest oczywiste, że kompilator nie wykonuje tej samej konwersji dla printfgniazd argumentów adresowanych przez %[efg].
zwol
1
@Voo: Jeśli interesuje Cię, jak strasznie źle może to pójść, weź pod uwagę, że w x86-64 SysV ABI argumenty zmiennoprzecinkowe są przekazywane w innym zestawie rejestrów niż argumenty całkowite.
EOF
1
@LightnessRacesinOrbit Myślę, że zawsze warto omówić, dlaczego coś jest UB, co zwykle wiąże się z rozmową o tym, jaka szerokość geograficzna implementacji jest dozwolona i co faktycznie dzieje się w typowych przypadkach.
zwol
4

"%f\n"gwarantuje przewidywalny wynik tylko wtedy, gdy drugi printf()parametr ma typ double. Następnie dodatkowe argumenty funkcji wariadycznych są przedmiotem domyślnej promocji argumentów. Argumenty całkowite są objęte promocją liczb całkowitych, co nigdy nie skutkuje wartościami zmiennoprzecinkowymi. I floatparametry są promowane nadouble .

Na domiar złego : standard pozwala na to, aby drugi argument był lub floatlub doublei nic więcej.

Sergio
źródło
4

Dlaczego formalnie jest to UB, zostało omówione w kilku odpowiedziach.

Powód, dla którego otrzymujesz takie zachowanie, jest zależny od platformy, ale prawdopodobnie jest następujący:

  • printfoczekuje swoich argumentów zgodnie ze standardową propagacją vararg. Oznacza to, że floatbędzie to doublei wszystko mniejsze niż intbędzie int.
  • Przechodzisz przez, intgdzie funkcja oczekuje pliku double. Twój intjest prawdopodobnie 32-bitowy, twój double64-bitowy. Oznacza to, że cztery bajty stosu rozpoczynające się w miejscu, w którym powinien znajdować się argument 0, znajdują się , ale kolejne cztery bajty mają dowolną zawartość. To jest używane do konstruowania wartości, która jest wyświetlana.
glglgl
źródło
0

Główną przyczyną tego problemu z „nieokreśloną wartością” jest rzutowanie wskaźnika na intwartość przekazaną do printfsekcji parametrów zmiennych do wskaźnika o doubletypach, któreva_arg makro.

Powoduje to odwołanie do obszaru pamięci, który nie został całkowicie zainicjowany wartością przekazaną jako parametr do printf, ponieważ doublerozmiar obszaru buforu pamięci jest większy niż introzmiar.

Dlatego też, gdy ten wskaźnik jest wyłuskiwany, zwracana jest nieokreślona wartość lub lepiej „wartość”, która zawiera częściowo wartość przekazaną jako parametr do printf, a dla pozostałej części może pochodzić z innego obszaru bufora stosu lub nawet obszaru kodu ( podnoszenie wyjątku błędu pamięci), prawdziwy przepełnienie bufora .


Może uwzględniać te konkretne fragmenty półproduktów implementacji kodu „printf” i „va_arg” ...

printf

va_list arg;
....
case('%f')
      va_arg ( arg, double ); //va_arg is a macro, and so you can pass it the "type" that will be used for casting the int pointer argument of printf..
.... 


rzeczywista implementacja w vprintf (biorąc pod uwagę GNU impl.) zarządzania przypadkami kodu parametrów o podwójnej wartości to:

if (__ldbl_is_dbl)
{
   args_value[cnt].pa_double = va_arg (ap_save, double);
   ...
}



va_arg

char *p = (double *) &arg + sizeof arg;  //printf parameters area pointer

double i2 = *((double *)p); //casting to double because va_arg(arg, double)
   p += sizeof (double);



Bibliografia

  1. implementacja "printf" (vprintf) w glibc projektu gnu)
  2. przykład kodu semplifikacji printf
  3. przykład kodu semplifikacji va_arg
Ciro Corvino
źródło