Forum: Compiler & IDEs Mehr UB erkennen (C++)


von Wilhelm M. (wimalopaan)


Angehängte Dateien:

Lesenswert?

Eine aktuelle Diskussion um UB beim type-punning via unions brachte mich 
zu der vorschnellen Aussage: der UB-sanitizer wird das aufdecken.

Folgender Code, der klar UB enthält:
1
to_type test1(const from_type d) {
2
    U u;
3
    u.d = d; // activate member d;
4
    return u.i; // read from inactive member (UB!)
5
}

Leider ist es aber tatsächlich so, dass der UB-sanitizer gerade diesen 
Fall nicht abdeckt.

Allerdings ist es auch so, dass in einem constexpr-Kontext jedes(!) UB 
verboten ist. Dazu kann man auch die Deklaration als
sog. immediate-function verwenden (consteval):
1
consteval to_type test1(const from_type d) {
2
    U u;
3
    u.d = d; // activate member d;
4
    return u.i; // read from inactive member (UB!)
5
}

(Bemerkung: der Optimizer darf die Funktion nicht entfernt haben)

Jetzt bekommt man wie erwartet den Fehler, dass hier UB vorliegt, und es 
lässt sich nicht compilieren.
1
error: accessing 'U::i' member instead of initialized 'U::d' member in constant expression

Wunderbar.

Macht man es richtig, ist es auch in einer immediate-funktion möglich:
1
consteval to_type test1(const from_type d) {
2
    U u;
3
    u.d = d; // activate member d;
4
    u.d.~from_type(); // end-of-life
5
    new (&u.i) to_type(); // begin-of-life
6
    return u.i; // read from now active member
7
}

Die "normale" Variante, die Objekt-Repräsentationen auszutauschen, geht 
für triviale Typen über std::memcpy(), wie wir alle wissen:
1
constexpr to_type test2(const from_type d) { // violates constexpr because of std::memcpy (use std::bit_cast)
2
    to_type r; // begin-of-life
3
    std::memcpy(&r, &d, (sizeof(from_type) > sizeof(to_type) ? sizeof(to_type): sizeof(from_type))); // copy-state (must be trivially-copyable)
4
    return r;
5
}

Natürlich ist std::memcpy() genauso effizient (noop) wie der union-Weg. 
Folgendes
1
auto foo() {
2
    constexpr from_type x;
3
    return test1(x);    
4
}
5
int main() {
6
    from_type x;
7
    return foo().m + test2(x).m;    
8
}

ergibt dann (from_type == to_type == char):
1
test1(char):
2
ret
3
.size   test1(char), .-test1(char)
4
.type   test2(char), @function
5
test2(char):
6
ret
7
.size   test2(char), .-test2(char)
8
.type   foo(), @function
9
foo():
10
ldi r24,lo8(1)   ; ,
11
ret
12
.size   foo(), .-foo()
13
.section        .text.startup,"ax",@progbits
14
.type   main, @function
15
main:
16
ldi r24,lo8(2)   ; ,
17
ldi r25,0                ;
18
ret

Bei einem punning von double -> int:
1
test1(double):
2
ret
3
.size   test1(double), .-test1(double)
4
.type   test2(double), @function
5
test2(double):
6
ret
7
.size   test2(double), .-test2(double)
8
.type   foo(), @function
9
foo():
10
ldi r22,0                ;
11
ldi r23,0                ;
12
ldi r24,lo8(-128)        ; ,
13
ldi r25,lo8(63)  ; ,
14
ret
15
.size   foo(), .-foo()
16
.section        .text.startup,"ax",@progbits
17
.type   main, @function
18
main:
19
ldi r25,0                ;
20
ldi r24,0                ;
21
ret

Man könnte daraus schließen, dass es trotz UB korrekt funktioniert.

Dass das aber nicht so ist, sieht man, wenn man statt primitiver 
Datentypen auch mal UDT verwendet, wie etwa:
1
struct S {
2
    constexpr S() {
3
        if (!std::is_constant_evaluated()) {
4
#ifdef USE_ASM
5
            asm(";S()");
6
#endif
7
        }
8
    }
9
    uint8_t m{42};
10
};
11
12
struct V {
13
    constexpr V() {
14
        if (!std::is_constant_evaluated()) {
15
#ifdef USE_ASM
16
            asm(";V()");
17
#endif
18
        }
19
    }
20
    uint8_t m{43};    
21
};

Das ergibt dann (ohne UB):
1
test1(S):
2
;test1()>
3
;V()
4
;test1()<
5
ldi r24,lo8(43)  ; ,
6
ret
7
.size   test1(S), .-test1(S)
8
.type   test2(S), @function
9
test2(S):
10
;test2()>
11
;V()
12
;test2()<
13
ret
14
.size   test2(S), .-test2(S)
15
.type   foo(), @function
16
foo():
17
;test1()>
18
;V()
19
;test1()<
20
ldi r24,lo8(43)  ; ,
21
ret
22
.size   foo(), .-foo()
23
.section        .text.startup,"ax",@progbits
24
.type   main, @function
25
main:
26
;S()
27
;test1()>
28
;V()
29
;test1()<
30
;test2()>
31
;V()
32
;test2()<
33
ldi r24,lo8(85)  ; ,
34
ldi r25,0                ;
35
ret


und wiedre mit UB:
1
test1(S):
2
;test1()>
3
;test1()<
4
ret
5
.size   test1(S), .-test1(S)
6
.type   test2(S), @function
7
test2(S):
8
;test2()>
9
;V()
10
;test2()<
11
ret
12
.size   test2(S), .-test2(S)
13
.type   foo(), @function
14
foo():
15
;test1()>
16
;test1()<
17
ldi r24,lo8(42)  ; ,
18
ret
19
.size   foo(), .-foo()
20
.section        .text.startup,"ax",@progbits
21
.type   main, @function
22
main:
23
;S()
24
;test1()>
25
;test1()<
26
;test2()>
27
;V()
28
;test2()<
29
ldi r24,lo8(84)  ; ,
30
ldi r25,0                ;
31
ret

Man sieht also gut, dass die Lebensdauer des V-Objektes bei der 
union-Variante nicht korrekt begonnen hat (der Konstruktor wurde nicht 
aufgerufen, und dieser könnte auch noch einen Seiteneffekt haben). Im 
Sinne von C++ gibt es das V-Objektes noch gar nicht. Daher UB.
Damit das mit der union-Variante korrekt funktioniert, d.h. das Objekt 
korrekt "geboren" wird und(!) die Objektrepräsentation des anderen 
übernimmt, braucht man also entweder noch einen 
Typumwandlungskonstruktor,
der dann auch korrekterweise aufgerufen werden müsste, oder etwa einen 
speziellen Konstruktor, der die Objektrepräsentation unangetastet lässt.

Setzt man dafür den Copy-Ctor ein, würde das bei double->int zusätzlich 
dazu führen, dass auch noch __fixfsi() aufgerufen wird, um das Runden 
Richtung 0 durchzuführen. Das ist dann aber eine andere Semantik als das 
direkte
Kopieren der Objektrepräsentation via std::memcpy().

Nachtrag: std::memcpy() ist nicht constexpr, kann also für 
immediate-funktions nicht eingesetzt werden (wir warten auf 
std::bit_cast()).

Bottom-line: immediate-functions decken jedes UB auf. Auch dort, wo der 
UB-Sanitizer das nicht macht (weil nicht implementiert, wie hier), oder 
wenn man auf seiner Plattform (bare-metal) keinen hat.

(Mit dem angehängten Beispiel kann man etwas herum spielen.)

von Johann L. (gjlayde) Benutzerseite


Lesenswert?

Wilhelm M. schrieb:
> Folgender Code, der klar UB enthält:
>
> to_type test1(const from_type d) {
>     U u;
>     u.d = d; // activate member d;
>     return u.i; // read from inactive member (UB!)
> }
>
> Leider ist es aber tatsächlich so, dass der UB-sanitizer gerade diesen
> Fall nicht abdeckt.

Hat vielleicht damit zu tun, dass es eine GCC Erweiterung ist?
1
To fix the code above, you can use a union instead of a cast (note that this is a GCC extension which might not work with other compilers):
2
3
    #include <stdio.h>
4
5
    int main()
6
    {
7
      union
8
      {
9
        short a[2];
10
        int i;
11
      } u;
12
13
      u.a[0]=0x1111;
14
      u.a[1]=0x1111;
15
16
      u.i = 0x22222222;
17
18
      printf("%x %x\n", u.a[0], u.a[1]);
19
      return 0;
20
    }
21
Now the result will always be "2222 2222".

Ist zwar unter "C" gelistet, aber die C/C++ Frontends teilen sich 
manchen Code. Dieses Punning wird zum Beispiel in der libgcc in 
float-Emulation verwendet, um an die interne Darstellung von float zu 
kommen.  libgcc ist aber auch in C geschrieben.

von Wilhelm M. (wimalopaan)


Lesenswert?

Johann L. schrieb:
> Hat vielleicht damit zu tun, dass es eine GCC Erweiterung ist?

Wie meinst Du das?

In C ist es ja erlaubt (auch aus den Gründen, die ich oben erläutert 
habe, warum es in C++ nicht erlaubt sein kann).

Der UB-sanitizer deckt diesen Fall laut Doku einfach nicht ab. Die Frage 
ist, warum er das nicht macht. Denn offensichtlich erkennt der GCC 
dieses UB ja, nur runtime ist eben NDR, und compile-time muss er es 
ablehnen.

von Heiko L. (zer0)


Lesenswert?

Wilhelm M. schrieb:
> Man sieht also gut, dass die Lebensdauer des V-Objektes bei der
> union-Variante nicht korrekt begonnen hat (der Konstruktor wurde nicht
> aufgerufen, und dieser könnte auch noch einen Seiteneffekt haben).

Das ist doch aber genau das, was man erwarten würde, wenn man 
type-punning anwendet: Das Objekt wird nicht konstruiert, sondern direkt 
aus der Reinterpretation einer Repräsentation gewonnen. Da die schon da 
ist, muss das Objekt halt schon konstruiert worden sein.

Dass type-punning ( auch mit gcc Erweiterung ) UB ist, liegt wohl eher 
an komplexeren Aliasing Effekten:
1
int *ip = &u.i;
2
3
... (viel mehr code - unmöglich alles nachzuvollziehen)
4
5
u.f = 2.0f;
6
*ip = 1;
7
cout << u.f;
hier verliert der Compiler ggf. das Tracking, und weiß nicht mehr, dass 
u.f durch einen Write auf *ip geändert werden kann. Die Doku der 
Erweiterung sagt auch, dass Aliasing nur bei direkten Zugriffen durch 
union member erkannt wird.

von Wilhelm M. (wimalopaan)


Lesenswert?

Heiko L. schrieb:
> Da die schon da
> ist, muss das Objekt halt schon konstruiert worden sein.

Nein, die Lebensdauer eines Objektes beginnt erst mit seiner 
Konstruktion, nicht mit dem Vorhandensein (irgendeine) einer 
Objektrepräsentation.
Man kann natürlich eine Typw.-ctor im placement-new aufrufen.

Heiko L. schrieb:
> Dass type-punning ( auch mit gcc Erweiterung ) UB ist, liegt wohl eher
> an komplexeren Aliasing Effekten

Nein.
Das Aliasing und die daraus ggf. folgende, falsche Optimierung hat mit 
dem UB des Type-punning nichts zu tun. Aber: eine Verletzung der 
strict-aliasing-rule ist selbst natürlich wieder UB.

von Heiko L. (zer0)


Lesenswert?

Wilhelm M. schrieb:
> Nein, die Lebensdauer eines Objektes beginnt erst mit seiner
> Konstruktion, nicht mit dem Vorhandensein (irgendeine) einer
> Objektrepräsentation.

Und doch ist es im Falle der union einfach da. Creatio ex nihilo. Wie 
mysteriös.

Wilhelm M. schrieb:
> Nein.
> Das Aliasing und die daraus ggf. folgende, falsche Optimierung hat mit
> dem UB des Type-punning nichts zu tun. Aber: eine Verletzung der
> strict-aliasing-rule ist selbst natürlich wieder UB.

Nur in theoretischem C++. In gcc funktioniert type-punning über unions 
problemlos mit der gemachten Einschränkung.

: Bearbeitet durch User
von Heiko L. (zer0)


Lesenswert?

Wilhelm M. schrieb:
> Man sieht also gut, dass die Lebensdauer des V-Objektes bei der
> union-Variante nicht korrekt begonnen hat (der Konstruktor wurde nicht
> aufgerufen, und dieser könnte auch noch einen Seiteneffekt haben).


Was mir da noch einfällt:
Bei einer Klasse
1
class X {
2
 X(int x) : m(x) { cout<<"called"; }
3
 X() : m(0) {}
4
5
 operator int() { return m; }
6
private:
7
 int m;
8
};
ist es eigentlich unmöglich, ein X zu beobachten, mit Wert != 0, ohne 
"called" als Ausgabe zu erhalten. Das stimmt natürlich auch schon mit 
memcpy nicht mehr.

von Sven P. (Gast)


Lesenswert?

Das sind diese Momente, wo ich mit dem Programmieren aufhören möchte und 
stattdessen lieber Besenstiele in Einheitslänge drechsle...

Höchst interessant ausgeführt von Wilhelm, aber m.M.n. leider auch immer 
mehr ein Zeichen dafür, wie schwer es mittlerweile geworden ist, moderne 
Programmiersprachen zu verstehen.

Ich mein, den Sachverhalt um Unions und dass nur das zuletzt 
beschriebene Element gelesen werden darf, weiß vermutlich "jeder" 
Programmierer. Aber die Implikationen in Gänze überblicken mit 
Sicherheit nur wenige.

von mh (Gast)


Lesenswert?

Heiko L. schrieb:
> Was mir da noch einfällt:
> Bei einer Klasseclass X {
>  X(int x) : m(x) { cout<<"called"; }
>  X() : m(0) {}
>
>  operator int() { return m; }
> private:
>  int m;
> };
> ist es eigentlich unmöglich, ein X zu beobachten, mit Wert != 0, ohne
> "called" als Ausgabe zu erhalten. Das stimmt natürlich auch schon mit
> memcpy nicht mehr.

Es geht nicht wirklich darum, ob ein spezieller Konstruktor aufgerufen 
wurde. Es existiert ein X, bevor du mit memcpy etwas hineinkopierst. 
Beim type-punning existiert kein X.

von Wilhelm M. (wimalopaan)


Lesenswert?

Heiko L. schrieb:
> Nur in theoretischem C++

Was ist das???

von Heiko L. (zer0)


Lesenswert?

mh schrieb:
> Es geht nicht wirklich darum, ob ein spezieller Konstruktor aufgerufen
> wurde. Es existiert ein X, bevor du mit memcpy etwas hineinkopierst.
> Beim type-punning existiert kein X.

Ja, nee - es geht darum, dass das "Seiteneffekte"-Argument ohnehin nicht 
ganz hält. Da spielt es eine Rolle.

von Heiko L. (zer0)


Lesenswert?

Wilhelm M. schrieb:
> Heiko L. schrieb:
>> Nur in theoretischem C++
>
> Was ist das???

Das, was ist, was es aber nicht gibt.

von Wilhelm M. (wimalopaan)


Lesenswert?

Sven P. schrieb:
> Ich mein, den Sachverhalt um Unions und dass nur das zuletzt
> beschriebene Element gelesen werden darf, weiß vermutlich "jeder"
> Programmierer.

Naja, da bin ich mir nicht so sicher ...

Mit diesem Beitrag wollte ich einfach mal es ganz klar machen, warum es 
in C++ (im Gegensatz zu C) nicht erlaubt sein kann. Sicher haben es 
viele Leute schon mal gehört. Doch die Antwort ist ja oft: es geht 
trotzdem.

Und das zweite, was ich wesentlich interessanter finde, ist, dass man 
jedes(!) UB mit einer immediate-function oder einer constexpr-function 
in einem constexpr-Kontext sichtbar machen kann. Zur Compilezeit ohne 
sanitizer (der es ja in diesem speziellen Fall auch gar nicht aufdeckt). 
Auch andere Sachen wie eine Verletzung der strict-aliasing-rule, etc. 
werden sofort sichtbar.

Leider werden wegen NDR solche Sachen normalerweise nicht gewarnt, 
obwohl der Compiler ja offensichtlich die Diagnose darüber stellt / 
stellen kann.

von Heiko L. (zer0)


Lesenswert?

Wilhelm M. schrieb:
> Und das zweite, was ich wesentlich interessanter finde, ist, dass man
> jedes(!) UB mit einer immediate-function oder einer constexpr-function
> in einem constexpr-Kontext sichtbar machen kann.

Wobei es vermutlich als bug anzusehen ist, dass der Compiler trotz 
entsprechender Warning-Flags das nicht von vornherein beanstandet. Oder 
war dein konkreter Use-Case so komplex, dass der da ausgestiegen ist?

Beitrag #6120518 wurde vom Autor gelöscht.
Beitrag #6120520 wurde vom Autor gelöscht.
von Guest (Gast)


Lesenswert?

Wilhelm M. schrieb:
> Doch die Antwort ist ja oft: es geht
> trotzdem.


Timur Doumler sagte hier: "Wenn das Verkehrsschild anweist, links zu 
fahren, kann es gut gehen trotzdem rechts zu fahren" ;-)

https://www.youtube.com/watch?v=_qzMpk-22cc

von Wilhelm M. (wimalopaan)


Lesenswert?

Guest schrieb:
> Wilhelm M. schrieb:
>> Doch die Antwort ist ja oft: es geht
>> trotzdem.
>
>
> Timur Doumler sagte hier: "Wenn das Verkehrsschild anweist, links zu
> fahren, kann es gut gehen trotzdem rechts zu fahren" ;-)
>
> https://www.youtube.com/watch?v=_qzMpk-22cc

Genau!

Und übrigens sind die strict-aliasing-rules und die daraus folgenden 
Optimierungen ein sehr starkes Argument für domänenspezifische 
Datentypen bzw. templates. Dann wird immer der optimale Code generiert, 
und man kommst gar nicht erst in die Versuchung, die Regeln zu 
verletzen.

von mh (Gast)


Lesenswert?

Heiko L. schrieb:
> mh schrieb:
>> Es geht nicht wirklich darum, ob ein spezieller Konstruktor aufgerufen
>> wurde. Es existiert ein X, bevor du mit memcpy etwas hineinkopierst.
>> Beim type-punning existiert kein X.
>
> Ja, nee - es geht darum, dass das "Seiteneffekte"-Argument ohnehin nicht
> ganz hält. Da spielt es eine Rolle.

Ich habe bei Wilhelm kein "Seiteneffekte"-Argument gesehen, es sei denn 
du meinst den Konjunktiv in Klammern hinter dem eigentlichen Argument. 
Es gibt auch bessere Beispiele mit korrektem C++, wo Konstruktoren mit 
"Seiteneffekten" nicht aufgerufen werden.

von Heiko L. (zer0)


Lesenswert?

mh schrieb:
> Heiko L. schrieb:
>> mh schrieb:
>>> Es geht nicht wirklich darum, ob ein spezieller Konstruktor aufgerufen
>>> wurde. Es existiert ein X, bevor du mit memcpy etwas hineinkopierst.
>>> Beim type-punning existiert kein X.
>>
>> Ja, nee - es geht darum, dass das "Seiteneffekte"-Argument ohnehin nicht
>> ganz hält. Da spielt es eine Rolle.
>
> Ich habe bei Wilhelm kein "Seiteneffekte"-Argument gesehen, es sei denn
> du meinst den Konjunktiv in Klammern hinter dem eigentlichen Argument.
> Es gibt auch bessere Beispiele mit korrektem C++, wo Konstruktoren mit
> "Seiteneffekten" nicht aufgerufen werden.

Naja, wenn das "eigentliche Argument" damit begründet wird, hinkt es. 
Ich bezweifle auch nicht, dass der Standard das als UB definiert. Das 
ist mir nur reichlich egal, weil C++ < 20 so lückenhaft ist, dass man, 
ohne auf Compiler-Extensions zurückzugreifen, eine z.T. unbrauchbare 
Sprache vor sich hat.

Edit: Ich sollte nicht "< 20" schreiben. Dass es sowieso immer 
(Definitions-)lücken geben muss sagt uns schon Kurt Gödel.

: Bearbeitet durch User
von Wilhelm M. (wimalopaan)


Lesenswert?

Heiko L. schrieb:
> Das
> ist mir nur reichlich egal, weil C++ < 20 so lückenhaft ist, dass man,
> ohne auf Compiler-Extensions zurückzugreifen, eine z.T. unbrauchbare
> Sprache vor sich hat.

Kannst Du diese Deine persönliche Einschätzung etwas begründen?

von Heiko L. (zer0)


Lesenswert?

Wilhelm M. schrieb:
> Heiko L. schrieb:
>> Das
>> ist mir nur reichlich egal, weil C++ < 20 so lückenhaft ist, dass man,
>> ohne auf Compiler-Extensions zurückzugreifen, eine z.T. unbrauchbare
>> Sprache vor sich hat.
>
> Kannst Du diese Deine persönliche Einschätzung etwas begründen?

Schau mal das Video oben: Eigentlich fängt fast jedes cppcon-Video damit 
an, das einer erzählt, warum man mit C++ theoretisch überhaupt gar 
nichts anfangen kann, weil, wenn man jetzt den und den Satz so und so 
interpretiert, auf einmal alles kaputt ist. Und da kann man immer noch 
abwarten: Irgendeine Definitionslücke gibt es immer.
Es ist also sowieso der wohlwollenden Auslegung der Definitionen zu 
verdanken, dass "Hello, World" keine Kernschmelze initiiert.

von Sven P. (Gast)


Lesenswert?

"Volatilität" im Zusammenhang mit Multithreading war ja auch lange etwas 
wackelig.

Ansonsten muss man ja nur mal einen Blick in die Liste mit den 
restlichen paar Core Language Defects werfen :)

von Wilhelm M. (wimalopaan)


Lesenswert?

Sven P. schrieb:
> "Volatilität" im Zusammenhang mit Multithreading war ja auch lange etwas
> wackelig.

Das hat aber gar nicht erstmal mit MT zu tun. Ein simpler 
Definitionsdefekt seit K&R.

von Sven P. (Gast)


Lesenswert?

Wilhelm M. schrieb:
> Sven P. schrieb:
>> "Volatilität" im Zusammenhang mit Multithreading war ja auch lange etwas
>> wackelig.
>
> Das hat aber gar nicht erstmal mit MT zu tun. Ein simpler
> Definitionsdefekt seit K&R.

Gab es bei K&R überhaupt schon das Konzept "MT"?
WIMRE gab es das selbst bei C89 und C99 noch nicht.

Man hat es halt mit "volatile" relativ breitbandig erschlagen.

von Wilhelm M. (wimalopaan)


Lesenswert?

Sven P. schrieb:
> Gab es bei K&R überhaupt schon das Konzept "MT"?

Jein. Nicht in Unix/Multics.D eswegen schrieb ich das ja auch. Aber auf 
anderen Systemen sehr wohl.

Dennis/Ritchie hatten das Problem der Nebenläufigkeit durch HW 
(Register).

Bitte melde dich an um einen Beitrag zu schreiben. Anmeldung ist kostenlos und dauert nur eine Minute.
Bestehender Account
Schon ein Account bei Google/GoogleMail? Keine Anmeldung erforderlich!
Mit Google-Account einloggen
Noch kein Account? Hier anmelden.