Koncepcja kryjąca się za tymi czterema wierszami trudnego kodu C.

384

Dlaczego ten kod daje wynik C++Sucks? Jaka jest za tym koncepcja?

#include <stdio.h>

double m[] = {7709179928849219.0, 771};

int main() {
    m[1]--?m[0]*=2,main():printf((char*)m);    
}

Sprawdź to tutaj .

Codelayer1
źródło
1
@BoBTFish technicznie tak, ale działa tak samo w C99: ideone.com/IZOkql
nijansen
12
@Nurettin Miałem podobne myśli. Ale to nie wina OP, to ludzie głosują na tę bezużyteczną wiedzę. Przyznaję, że ten kod zaciemniający może być interesujący, ale wpisz „zaciemnianie” w Google i uzyskasz mnóstwo wyników w każdym formalnym języku, jaki możesz wymyślić. Nie zrozum mnie źle, zadaję tutaj takie pytanie. Jest to jednak przereklamowane, ponieważ niezbyt przydatne pytanie.
TobiMcNamobi
6
@ detonator123 „Musisz być nowy tutaj” - jeśli spojrzysz na przyczynę zamknięcia, możesz dowiedzieć się, że tak nie jest. W twoim pytaniu wyraźnie brakuje wymaganego minimalnego zrozumienia - „nie rozumiem tego, wyjaśnij to” nie jest czymś pożądanym w przypadku przepełnienia stosu. Jeśli najpierw spróbowałbyś czegoś sam , pytanie nie zostałoby zamknięte. To proste, aby google „podwójna reprezentacja C” lub tym podobne.
42
Moja wielkoformatowa maszyna PowerPC drukuje się skcuS++C.
Adam Rosenfield
27
Moje słowo, nienawidzę takich wymyślonych pytań. To trochę wzorzec w pamięci, który okazuje się być taki sam jak jakiś głupi ciąg. Nie służy to żadnemu pożytecznemu celowi, a mimo to zarabia setki punktów powtórzeń zarówno dla pytającego, jak i dla odpowiadającego. Tymczasem trudne pytania, które mogą być przydatne dla ludzi, mogą zdobyć garść punktów, jeśli w ogóle. Jest to rodzaj plakatu dziecka, co jest nie tak z SO.
Carey Gregory

Odpowiedzi:

494

Liczba 7709179928849219.0ma następującą reprezentację binarną jako wersję 64-bitową double:

01000011 00111011 01100011 01110101 01010011 00101011 00101011 01000011
+^^^^^^^ ^^^^---- -------- -------- -------- -------- -------- --------

+pokazuje pozycję znaku; ^wykładnika i -mantysy (tj. wartości bez wykładnika).

Ponieważ reprezentacja wykorzystuje wykładnik binarny i mantysę, podwojenie liczby zwiększa wykładnik o jeden. Twój program robi to dokładnie 771 razy, więc wykładnik, który zaczął się od 1075 (dziesiętna reprezentacja 10000110011), staje się na końcu 1075 + 771 = 1846; binarna reprezentacja 1846 jest 11100110110. Powstały wzór wygląda następująco:

01110011 01101011 01100011 01110101 01010011 00101011 00101011 01000011
-------- -------- -------- -------- -------- -------- -------- --------
0x73 's' 0x6B 'k' 0x63 'c' 0x75 'u' 0x53 'S' 0x2B '+' 0x2B '+' 0x43 'C'

Ten wzór odpowiada drukowanemu ciągowi znaków, tylko do tyłu. W tym samym czasie drugi element tablicy staje się zerowy, co oznacza zerowy terminator, dzięki czemu ciąg jest odpowiedni do przekazania printf().

dasblinkenlight
źródło
22
Dlaczego ciąg jest do tyłu?
Derek
95
@Derek x86 jest little-endian
Angew nie jest już dumny z SO
16
@Derek Wynika to z endianizmu specyficznego dla platformy : bajty abstrakcyjnej reprezentacji IEEE 754 są przechowywane w pamięci pod malejącymi adresami, więc łańcuch jest drukowany poprawnie. Na sprzęcie o dużej endianowości należałoby zacząć od innej liczby.
dasblinkenlight
14
@AlvinWong Masz rację, standard nie wymaga IEEE 754 ani żadnego innego określonego formatu. Ten program jest tak mało przenośny, jak to tylko możliwe, lub bardzo blisko :-)
dasblinkenlight
10
@GrijeshChauhan Użyłem podwójnej precyzji IEEE 754 kalkulatora : I wklejony 7709179928849219wartość, a dostał reprezentacji dwójkowej plecy.
dasblinkenlight
223

Bardziej czytelna wersja:

double m[2] = {7709179928849219.0, 771};
// m[0] = 7709179928849219.0;
// m[1] = 771;    

int main()
{
    if (m[1]-- != 0)
    {
        m[0] *= 2;
        main();
    }
    else
    {
        printf((char*) m);
    }
}

Wzywa rekurencyjnie main()771 razy.

Na początku m[0] = 7709179928849219.0, który stoi za C++Suc;C. W każdym połączeniu m[0]podwaja się, aby „naprawić” ostatnie dwie litery. W ostatnim wywołaniu m[0]zawiera znak ASCII reprezentujący C++Sucksi m[1]zawiera tylko zera, więc ma zerowy terminator dla C++Sucksłańcucha. Wszystko przy założeniu, że m[0]jest przechowywane na 8 bajtach, więc każdy znak zajmuje 1 bajt.

Bez rekurencji i nielegalnych main()połączeń będzie to wyglądać następująco:

double m[] = {7709179928849219.0, 0};
for (int i = 0; i < 771; i++)
{
    m[0] *= 2;
}
printf((char*) m);
Adam Stelmaszczyk
źródło
8
To jest zmniejszenie postfiksów. Będzie to nazywało się 771 razy.
Jack Aidley
106

Zastrzeżenie: Ta odpowiedź została zamieszczona w oryginalnej formie pytania, w której wspomniano tylko o C ++ i zawierała nagłówek C ++. Społeczność dokonała konwersji pytania na czyste C bez udziału pierwotnego pytającego.


Formalnie rzecz biorąc, nie można wnioskować o tym programie, ponieważ jest źle sformułowany (tzn. Nie jest legalnym C ++). Narusza C ++ 11 [basic.start.main] p3:

Funkcja main nie powinna być używana w programie.

Poza tym opiera się na fakcie, że na typowym komputerze konsumenckim a doublema 8 bajtów i wykorzystuje pewną dobrze znaną reprezentację wewnętrzną. Początkowe wartości tablicy są obliczane tak, że gdy wykonywany jest „algorytm”, końcowa wartość pierwszego doublebędzie taka, że ​​wewnętrzna reprezentacja (8 bajtów) będzie kodami ASCII 8 znaków C++Sucks. Drugi element w tablicy jest wtedy 0.0, którego pierwszy bajt znajduje się 0w wewnętrznej reprezentacji, co czyni go prawidłowym łańcuchem w stylu C. Jest on następnie wysyłany do wyjścia za pomocą printf().

Uruchomienie tego na HW, gdzie niektóre z powyższych nie ma miejsca, spowoduje zamiast tego tekst śmieci (lub nawet dostęp poza granicami).

Angew nie jest już dumny z SO
źródło
25
Muszę dodać, że nie jest to wynalazek C ++ 11 - C ++ 03 miał również basic.start.main3.6.1 / 3 o tym samym brzmieniu.
sharptooth
1
Celem tego małego przykładu jest zilustrowanie tego, co można zrobić w C ++. Magiczna próbka przy użyciu sztuczek UB lub ogromnych pakietów oprogramowania „klasycznego” kodu.
SChepurin,
1
@sharptooth Dziękujemy za dodanie tego. Nie chciałem sugerować inaczej, po prostu zacytowałem zastosowany standard.
Angew nie jest już dumny z SO
@Angew: Tak, rozumiem to, chciałem tylko powiedzieć, że sformułowanie jest dość stare.
sharptooth
1
@JimBalter Notice Powiedziałem „formalnie rzecz biorąc, nie można tego uzasadnić”, a nie „nie można formalnie uzasadnić”. Masz rację, że można wnioskować o programie, ale musisz znać szczegóły kompilatora używanego do tego. Kompilator miałby w pełni prawo po prostu wyeliminować wywołanie main()lub zastąpić je wywołaniem API, aby sformatować dysk twardy lub cokolwiek innego.
Angew nie jest już dumny z SO
57

Być może najłatwiejszym sposobem na zrozumienie kodu jest praca w odwrotnej kolejności. Zaczniemy od łańcucha do wydrukowania - dla równowagi użyjemy „C ++ Rocks”. Kluczowy punkt: dokładnie tak jak oryginał, ma dokładnie osiem znaków. Ponieważ zrobimy (z grubsza) jak oryginał i wydrukujemy go w odwrotnej kolejności, zaczniemy od umieszczenia go w odwrotnej kolejności. W naszym pierwszym kroku po prostu zobaczymy ten wzór bitowy jako doublei wydrukujemy wynik:

#include <stdio.h>

char string[] = "skcoR++C";

int main(){
    printf("%f\n", *(double*)string);
}

To produkuje 3823728713643449.5. Chcemy więc manipulować tym w sposób, który nie jest oczywisty, ale łatwo go odwrócić. Pół arbitralnie wybiorę mnożenie przez 256, co daje nam 978874550692723072. Teraz wystarczy napisać zaciemniony kod, aby podzielić przez 256, a następnie wydrukować poszczególne jego bajty w odwrotnej kolejności:

#include <stdio.h>

double x [] = { 978874550692723072, 8 };
char *y = (char *)x;

int main(int argc, char **argv){
    if (x[1]) {
        x[0] /= 2;  
        main(--x[1], (char **)++y);
    }
    putchar(*--y);
}

Teraz mamy wiele rzutowania, przekazywania argumentów do (rekurencyjnego), mainktóre są całkowicie ignorowane (ale ocena, aby uzyskać przyrost i spadek są absolutnie kluczowe), i oczywiście ta całkowicie arbitralnie wyglądająca liczba, aby ukryć fakt, że to, co robimy jest naprawdę bardzo proste.

Oczywiście, ponieważ cała sprawa dotyczy zaciemnienia, jeśli mamy na to ochotę, możemy również podjąć więcej kroków. Na przykład możemy skorzystać z oceny zwarć, aby zamienić nasze ifstwierdzenie w jedno wyrażenie, więc główna część wygląda tak:

x[1] && (x[0] /= 2,  main(--x[1], (char **)++y));
putchar(*--y);

Dla każdego, kto nie jest przyzwyczajony do zaciemniania kodu (i / lub kodu golfa), zaczyna to naprawdę wyglądać dość dziwnie - obliczanie i odrzucanie logiki andjakiejś bezsensownej liczby zmiennoprzecinkowej i wartości zwracanej od main, która nawet nie zwraca wartość. Co gorsza, bez uświadomienia sobie (i zastanowienia się), jak działa ocena zwarcia, może nawet nie być od razu oczywiste, w jaki sposób unika nieskończonej rekurencji.

Naszym następnym krokiem byłoby prawdopodobnie oddzielne wydrukowanie każdej postaci od jej znalezienia. Możemy to zrobić dość łatwo, generując odpowiedni znak jako wartość zwrotną maini drukując to, co mainzwraca:

x[1] && (x[0] /= 2,  putchar(main(--x[1], (char **)++y)));
return *--y;

Przynajmniej dla mnie to wydaje się dość zaciemnione, więc zostawię to przy tym.

Jerry Coffin
źródło
1
Uwielbiam podejście kryminalistyczne.
ryyker
24

Po prostu buduje podwójną tablicę (16 bajtów), która - interpretowana jako tablica char - buduje kody ASCII dla ciągu „C ++ Sucks”

Jednak kod nie działa w każdym systemie, opiera się na niektórych z następujących niezdefiniowanych faktów:

DR
źródło
12

Zostanie wydrukowany następujący kod C++Suc;C, więc całe mnożenie dotyczy tylko dwóch ostatnich liter

double m[] = {7709179928849219.0, 0};
printf("%s\n", (char *)m);
Podawaj Laurijssen
źródło
11

Inni wyjaśnili pytanie dość dokładnie, chciałbym dodać uwagę, że jest to zachowanie niezdefiniowane zgodnie ze standardem.

C ++ 11 3.6.1 / 3 Funkcja główna

Funkcja main nie powinna być używana w programie. Połączenie (3.5) main jest zdefiniowane w implementacji. Program, który definiuje main jako usunięty lub deklaruje main jako wbudowany, statyczny lub constexpr, jest źle sformułowany. Nazwa main nie jest inaczej zastrzeżona. [Przykład: funkcje składowe, klasy i wyliczenia można nazwać main, podobnie jak encje w innych przestrzeniach nazw. —Przykład]

Yu Hao
źródło
1
Powiedziałbym, że jest nawet źle sformułowany (jak to zrobiłem w mojej odpowiedzi) - narusza to „powinien”.
Angew nie jest już dumny z SO
9

Kod można przepisać w następujący sposób:

void f()
{
    if (m[1]-- != 0)
    {
        m[0] *= 2;
        f();
    } else {
          printf((char*)m);
    }
}

To, co robi, tworzy zestaw bajtów w doubletablicy, mktóre przypadkowo odpowiadają znakom „C ++ Sucks”, po których następuje null-terminator. Ukryli kod, wybierając podwójną wartość, która podwojona 771 razy daje w standardowej reprezentacji ten zestaw bajtów z terminatorem zerowym dostarczonym przez drugi element tablicy.

Zauważ, że ten kod nie działałby pod inną reprezentacją endian. Również dzwonienie main()nie jest ściśle dozwolone.

Jack Aidley
źródło
3
Dlaczego twój fzwrot int?
leftaroundabout
1
Er, bo bezmyślnie kopiowałem intzwrot z pytania. Pozwól mi to naprawić.
Jack Aidley
1

Najpierw powinniśmy przypomnieć, że liczby podwójnej precyzji są przechowywane w pamięci w formacie binarnym w następujący sposób:

(i) 1 bit dla znaku

(ii) 11 bitów dla wykładnika

(iii) 52 bity dla wielkości

Kolejność bitów zmniejsza się z (i) do (iii).

Najpierw dziesiętna liczba ułamkowa jest konwertowana na równoważną ułamkową liczbę binarną, a następnie jest wyrażana jako postać wielkości w formacie binarnym.

Tak więc staje się liczba 7709179928849219.0

(11011011000110111010101010011001010110010101101000011)base 2


=1.1011011000110111010101010011001010110010101101000011 * 2^52

Teraz rozważając bity jasności 1. jest zaniedbywany, ponieważ cała metoda rzędu wielkości powinna zaczynać się od 1.

Więc część wielkości staje się:

1011011000110111010101010011001010110010101101000011 

Teraz potęga 2 wynosi 52 , musimy dodać do niej liczbę odchylenia jako 2 ^ (bity dla wykładnika -1) -1, tj. 2 ^ (11 -1) -1 = 1023 , więc nasz wykładnik staje się 52 + 1023 = 1075

Teraz nasz kod mnoży liczbę 2 , 771 razy, co powoduje wzrost wykładnika o 771

Zatem naszym wykładnikiem jest (1075 + 771) = 1846, którego ekwiwalent binarny to (11100110110)

Teraz nasza liczba jest dodatnia, więc nasz bit znaku ma wartość 0 .

Nasz zmodyfikowany numer staje się:

znak bitu + wykładnik + wielkość (prosta konkatenacja bitów)

0111001101101011011000110111010101010011001010110010101101000011 

ponieważ m jest konwertowane na wskaźnik char, podzielimy wzór bitowy na 8 części z LSD

01110011 01101011 01100011 01110101 01010011 00101011 00101011 01000011 

(którego ekwiwalent szesnastkowy to :)

 0x73 0x6B 0x63 0x75 0x53 0x2B 0x2B 0x43 

WYKRES ASCII Który z mapy postaci, jak pokazano, to:

s   k   c   u      S      +   +   C 

Teraz, gdy to zostanie zrobione, m [1] wynosi 0, co oznacza znak NULL

Teraz zakładając, że uruchamiasz ten program na maszynie little-endian (bit niższego rzędu jest przechowywany w dolnym adresie), więc wskaźnik m wskazuje na najniższy bit adresu, a następnie kontynuuje przyjmowanie bitów w uchwytach po 8 (jak typ rzutowany na char * ), a printf () zatrzymuje się, gdy napotka 00000000 w ostatnim chunck ...

Ten kod nie jest jednak przenośny.

Abhishek Ghosh
źródło