Funkcja nie wywołana w kodzie zostanie wywołana w czasie wykonywania

Jak poniższy program może wywołać never_called Jeśli nigdy kod?

#include <cstdio>

static void never_called()
{
  std::puts("formatting hard disk drive!");
}

static void (*foo)() = nullptr;

void set_foo()
{
  foo = never_called;
}

int main()
{
  foo();
}

To różni się w zależności od kompilatora. Kompilowanie z Clang z optymalizacje na, funkcja never_called wykonuje w czasie wykonywania.

$ clang++ -std=c++17 -O3 a.cpp && ./a.out
formatting hard disk drive!

Kompilowanie z GCC, jednak ten kod po prostu się zawiesza:

$ g++ -std=c++17 -O3 a.cpp && ./a.out
Segmentation fault (core dumped)

Wersja kompilatora:

$ clang --version
clang version 5.0.0 (tags/RELEASE_500/final)
Target: x86_64-unknown-linux-gnu
Thread model: posix
InstalledDir: /usr/bin
$ gcc --version
gcc (GCC) 7.2.1 20171128
Copyright (C) 2017 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
Author: Mário Feroldi, 2018-01-02

2 answers

Program zawiera nieokreślone zachowanie, jako dereferowanie wskaźnika null (tzn. wywołanie foo() W main Bez przypisywania do niego poprawnego adresu wcześniej) jest UB, dlatego norma nie nakłada żadnych wymagań.

Wykonywanie never_called w czasie wykonywania jest idealną sytuacją, gdy undefined behavior has been hit, it ' s as valid as just crashing (like po skompilowaniu z GCC). Dobra, ale dlaczego Clang to robi? Jeśli skompilować go z wyłączonymi optymalizacjami, program nie będzie już wyjście "formatowanie dysku twardego" i po prostu się zawiesi:

$ clang++ -std=c++17 -O0 a.cpp && ./a.out
Segmentation fault (core dumped)

Wygenerowany kod dla tej wersji jest następujący:

main:                                   # @main
        push    rbp
        mov     rbp, rsp
        call    qword ptr [foo]
        xor     eax, eax
        pop     rbp
        ret

Próbuje wywołać funkcję, do której foo wskazuje, a jako foo jest inicjalizowana za pomocą nullptr (lub jeśli nie ma inicjalizacji, nadal tak będzie), jego wartość wynosi zero. Tutaj, undefined zachowanie zostało uderzone, więc wszystko może się w ogóle zdarzyć, a program jest bezużyteczny. Normalnie, wykonanie połączenia do takiego nieważnego adres skutkuje błędami segmentacji, stąd komunikat, który otrzymujemy, gdy uruchamianie programu.

Teraz zbadajmy ten sam program, ale skompilujmy go z optymalizacjami na:

$ clang++ -std=c++17 -O3 a.cpp && ./a.out
formatting hard disk drive!

Wygenerowany kod dla tej wersji jest następujący:

set_foo():                            # @set_foo()
        ret
main:                                   # @main
        push    rax
        mov     edi, .L.str
        call    puts
        xor     eax, eax
        pop     rcx
        ret
.L.str:
        .asciz  "formatting hard disk drive!"

Co ciekawe, w jakiś sposób optymalizacje zmodyfikowały program tak, aby main wywołuje std::puts bezpośrednio. Ale dlaczego Clang to zrobił? I dlaczego jest set_foo skompilowane do pojedynczej ret instrukcji?

Wróćmy do standardowo (konkretnie N4660) na chwilę. Co? mówi o nieokreślonym zachowaniu?

3.27 undefined behavior [defns.undefined]

Zachowanie, dla którego niniejszy dokument nie nakłada żadnych wymagań

[Uwaga: można oczekiwać niezdefiniowanego zachowania , gdy ten dokument pomija Dowolna jednoznaczna definicja zachowania lub , gdy program używa błędnego konstruowania lub błędnych danych. dopuszczalne nieokreślone zakresy zachowań od ignorowanie sytuacji całkowicie z nieprzewidywalnymi skutkami, aby zachowanie podczas tłumaczenia lub wykonanie programu w udokumentowany sposób charakterystyczne dla środowiska (z wydaniem lub bez wydania komunikat diagnostyczny), do zakończenia tłumaczenia lub wykonania (z wydanie komunikatu diagnostycznego). Wiele błędnych konstrukcji programu nie powodują nieokreślonego zachowania; są one wymagane do zdiagnozowania. Ocena wyrażenia stałego nigdy wykazuje zachowanie jawnie specified as undefined ([expr.const]). - Uwaga końcowa]

/ Align = "left" /

Program, który wykazuje nieokreślone zachowanie, staje się bezużyteczny, ponieważ wszystko zrobił to do tej pory i zrobi dalej nie ma znaczenia, jeśli zawiera błędne dane lub konstrukcje. Mając to na uwadze, pamiętaj, że Kompilatory mogą całkowicie ignorować w przypadku niezdefiniowanego zachowania jest trafiony, a to faktycznie jest używane jako odkryte fakty podczas optymalizacji program. Na przykład, konstrukt taki jak x + 1 > x (gdzie x jest liczbą całkowitą podpisaną) zostanie skompilowany do true, nawet jeśli wartość x jest nieznana podczas kompilacji. Rozumowanie jest to, że kompilator chce zoptymalizować dla ważnych przypadków, a jedynym sposobem na poprawność tego konstruktu jest to, że nie uruchamia arytmetyki przepełnienie (tj. if x != std::numeric_limits<decltype(x)>::max()). To to nowy wyuczony fakt w optymalizatorze. Na tej podstawie konstrukcja jest / align = "left" /

Uwaga: ta sama optymalizacja nie może występuje dla niepodpisanych liczb całkowitych, ponieważ przepełnienie nie jest UB. Oznacza to, że kompilator musi zachować wyrażenie w takim stanie, w jakim jest, ponieważ może mieć inną ocenę, gdy przepełnia się (unsigned to moduł 2 N , gdzie N to liczba bitów). Optymalizacja go dla niepodpisanych liczb całkowitych byłaby niezgodna ze standardem (dzięki aschepler.)

Jest to przydatne, ponieważ pozwala na Tony optymalizacji do kopnięcia w . Więc daleko, tak dobrze, ale co się stanie, jeśli x trzyma swój maksymalna wartość w czasie wykonywania? Cóż, to jest nieokreślone zachowanie, więc nonsensem jest próbować rozumować o to, jak wszystko może się zdarzyć, a norma nie nakłada żadnych wymagań.

Teraz mamy wystarczająco dużo informacji, aby lepiej zbadać twój błąd program. Wiemy już, że dostęp do wskaźnika null jest niezdefiniowany zachowanie, i to jest przyczyną zabawnego zachowania w czasie pracy. Spróbujmy więc zrozumieć, dlaczego clang (lub technicznie LLVM) zoptymalizowany program w jaki sposób tak.

static void (*foo)() = nullptr;

static void never_called()
{
  std::puts("formatting hard disk drive!");
}

void set_foo()
{
  foo = never_called;
}

int main()
{
  foo();
}

Pamiętaj, że możliwe jest wywołanie set_foo przed wpisem main rozpoczyna egzekucję. Na przykład, gdy najwyższy poziom deklaruje zmienną, można ją wywołać podczas inicjalizacji wartości tej zmiennej:

void set_foo();
int x = (set_foo(), 42);

Jeśli napiszesz ten fragment przed main, program nie dłużej wykazuje nieokreślone zachowanie, a komunikat " formatowanie twarde dysk!" jest wyświetlany z optymalizacjami włączonymi lub wyłączonymi.

Więc jaki jest jedyny sposób, aby ten program ważne? Jest to set_foo funkcja, która przypisuje adres never_called do foo, więc możemy znajdź coś tutaj. Zauważ, że foo jest oznaczony jako static, co oznacza, że ma wewnętrzne powiązanie i nie można uzyskać dostępu spoza tego tłumaczenia unit. Natomiast funkcja set_foo ma powiązania zewnętrzne i może być dostępne z zewnątrz. Jeśli inna jednostka tłumaczeń zawiera fragment podobnie jak ten powyżej, wtedy ten program staje się ważny.

Fajne, ale nikt nie dzwoni set_foo z Na Zewnątrz. Chociaż to jest fakt, optymalizator widzi, że jedynym sposobem, aby ten program be valid is if {[15] } is called before main, otherwise it ' s tylko nieokreślone zachowanie. Jest to nowy wyuczony fakt i zakłada set_foo jest w rzeczywistości nazywany. W oparciu o tę nową wiedzę, inne optymalizacje, które kopnięcie może to wykorzystać.

Na przykład, gdy stała składanie jest zastosowany, widzi, że konstrukcja foo() jest ważna tylko wtedy, gdy foo może być prawidłowo zainicjowana. Jedynym sposobem na to jest wywołanie {[15] } poza tą jednostką translacji, więc foo = never_called.

Eliminacja Martwego kodu i optymalizacja interproceduralna może się okazać, że jeśli foo == never_called, to kod wewnątrz set_foo jest niepotrzebny, przekształca się więc w pojedynczą ret instrukcję.

Inline expansion optymalizacja widzi, że foo == never_called, więc wezwanie do foo można zastąpić swoim ciałem. W końcu kończymy z czymś takim jak to:

set_foo():
        ret
main:
        mov     edi, .L.str
        call    puts
        xor     eax, eax
        ret
.L.str:
        .asciz  "formatting hard disk drive!"

, który jest w pewnym sensie odpowiednikiem wyjścia Clang z włączonymi optymalizacjami. Oczywiście to, co naprawdę zrobił Clang, może (i może) być inne, ale optymalizacje są jednak w stanie osiągnąć ten sam wniosek.

W przeciwieństwie do poprzednich wersji GCC, GCC nie może być używany w grach komputerowych.]}
.LC0:
        .string "formatting hard disk drive!"
never_called():
        mov     edi, OFFSET FLAT:.LC0
        jmp     puts
set_foo():
        mov     QWORD PTR foo[rip], OFFSET FLAT:never_called()
        ret
main:
        sub     rsp, 8
        call    [QWORD PTR foo[rip]]
        xor     eax, eax
        add     rsp, 8
        ret

Wykonanie tego programu powoduje awarię (błąd segmentacji), ale jeśli wywołasz set_foo w innej jednostce tłumaczeniowej, zanim main dostanie wykonanym, wtedy ten program nie wykazuje już niezdefiniowanego zachowania.

Wszystko to może się zmienić szalenie jak coraz więcej optymalizacji są projektowane, więc nie polegaj na założeniu, że Twój kompilator zajmie się kodem zawierającym niezdefiniowanego zachowania, to może po prostu spieprzyć, jak również (i sformatować dysk twardy na serio!)


Polecam przeczytać co każdy programista C powinien wiedzieć o niezdefiniowanym zachowaniu I Przewodnik po Undefined Zachowanie w C i C++, obie serie artykułów są bardzo pouczające i mogą pomóc w zrozumieniu stanu wiedzy.

 37
Author: Mário Feroldi,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2018-03-03 11:53:15

Jeśli implementacja nie określi efektu wywołania wskaźnika funkcji null, może zachowywać się jak wywołanie dowolnego kodu. Taki arbitralny kod może doskonale zachowywać się jak wywołanie funkcji " foo ()". Podczas gdy Załącznik L do standardu C zachęcałby implementacje do rozróżnienia między "Critical UB" i "non-critical UB" , a niektóre implementacje C++ mogłyby zastosować podobne rozróżnienie, wywołanie nieprawidłowego wskaźnika funkcji byłoby critical ub w dowolnym case.

Zauważ, że sytuacja w tym pytaniu jest bardzo różna od np.

unsigned short q;
unsigned hey(void)
{
  if (q < 50000)
    do_something();
  return q*q;
}

W tej ostatniej sytuacji kompilator, który nie twierdzi, że jest" analizowalny", może rozpoznać, że kod wywoła się, jeśli q jest większe niż 46,340, gdy wykonanie osiągnie return, a zatem równie dobrze może bezwarunkowo wywoływać do_something(). Chociaż Załącznik L jest źle napisany, wydaje się, że intencją byłoby zabronienie takich "optymalizacji". W przypadku wywołania nieprawidłowej funkcji wskaźnik, jednak nawet bezpośrednio generowany kod na większości platform może mieć dowolne zachowanie.

 0
Author: supercat,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2018-01-03 02:19:33