这四行巧妙的C代码背后的概念

390

为什么这段代码会输出 C++Sucks?背后的概念是什么?

#include <stdio.h>

double m[] = {7709179928849219.0, 771};

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

这里进行测试。


1
@BoBTFish 技术上是的,但它在 C99 中仍然运行相同:http://ideone.com/IZOkql - nikolas
12
我有类似的想法。但这不是楼主的错,而是投票支持这种无用的知识的人的错。诚然,代码混淆可能很有趣,但在谷歌上输入“obfuscation”,你会得到几乎所有正式语言的大量结果。别误会,我认为在这里问这样的问题没问题。只是这个问题被高估了,因为它不是非常有用的问题。 - TobiMcNamobi
6
“你一定是新来的”-如果你看关闭原因,就会发现这不是情况。你的问题明显缺少必要的基础知识——“我不理解这个,请解释一下”在Stack Overflow上是不被欢迎的。如果你自己先尝试了一些东西,那么这个问题就不会被关闭了。通过谷歌搜索“C语言双重表示”之类的问题很容易得到答案。 - user529758
43
我的大端 PowerPC 机器打印出了 skcuS++C - Adam Rosenfield
29
我讨厌像这样的做作问题。它们只是一些毫无用处的记忆模式,恰巧与某些愚蠢的字符串相同。对任何人都没有实际用途,但提问者和回答者却因此获得了数百个声望点数。与此同时,那些可能对人们有用的难题只能获得一些点数,甚至根本没有。这是 Stack Overflow 存在的问题的一个典型例子。 - Carey Gregory
显示剩余6条评论
9个回答

500

数字7709179928849219.0在64位的double浮点数中的二进制表示如下:

1101101011000111101110100010001111011111101100111101000011

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

+表示符号的位置;^表示指数的位置;-表示幂次(即不包括指数的值)的位置。

由于该表示法使用二进制指数和幂次,将数字加倍会使指数增加1。您的程序精确地执行了这个过程771次,因此起始时为1075(十进制表示为10000110011)的指数在最后变成了1075 + 771 = 1846;1846的二进制表示为11100110110。结果模式看起来像这样:

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

这个模式对应于你看到的字符串,只是反过来。同时,数组的第二个元素变为零,提供了空终止符,使得字符串适合传递给printf()函数。


22
为什么字符串是反着的? - Derek
97
@Derek x86 是小端模式。 - Angew is no longer proud of SO
17
这是由于特定平台的字节序影响造成的:抽象的IEEE 754表示中的字节按照递减的地址顺序存储在内存中,因此字符串正确打印。在具有大端字节序的硬件上,需要从不同的数字开始。 - Sergey Kalinichenko
14
你说得对,标准并不要求使用IEEE 754或任何其他特定的格式。这个程序缺乏可移植性,或者非常接近于缺乏可移植性。 :-) - Sergey Kalinichenko
10
我使用一款双精度IEEE754计算器:我复制了7709179928849219的值,并得到其二进制表示。 - Sergey Kalinichenko
显示剩余8条评论

226
更易读的版本:
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);
    }
}

它递归调用 main() 函数 771 次。

一开始,m[0] = 7709179928849219.0,它代表的是 C++Suc;C。在每次调用中,m[0] 被加倍,以“修复”最后两个字母。在最后一次调用中,m[0] 包含了 C++Sucks 的 ASCII 字符表示,而 m[1] 仅包含零,因此它为 C++Sucks 字符串设置了一个空终止符。所有这些都建立在假设m[0]存储在8字节上,因此每个字符占用1个字节。

如果没有递归和非法的main()调用,它将如下所示:

double m[] = {7709179928849219.0, 0};
for (int i = 0; i < 771; i++)
{
    m[0] *= 2;
}
printf((char*) m);

8
这是后缀递减。因此,它将被调用771次。 - Jack Aidley
有趣的事实:在C ++中调用“main”是非法的。 - user253751

107

免责声明:本答案发布于原问题的形式,其中只提到了C++并包含了一个C++头文件。将问题转换为纯C是由社区完成的,没有原始提问者的输入。


严格来说,这个程序无法进行推理,因为它不合法(即不是合法的C++)。它违反了C++11[basic.start.main]p3中的规定:

函数main不得在程序内使用。

除此之外,它依赖于一个事实,即在典型的消费电脑上,double长度为8字节,并使用某种众所周知的内部表示。数组的初始值是计算出来的,以便在执行“算法”时,第一个double的最终值将是内部表示(8字节)的8个字符C++Sucks的ASCII码。然后,数组中的第二个元素是0.0,其第一个字节是内部表示中的0,使其成为有效的C风格字符串。然后使用printf()将其发送到输出。

在一些不满足上述条件的硬件上运行此程序将导致垃圾文本(或甚至越界访问)。


25
我需要补充一点,这不是C++11的发明——C++03同样有使用相同措辞的basic.start.main 3.6.1/3。 - sharptooth
1
这个小例子的目的是为了说明C++可以做什么。不是使用UB技巧或者“经典”代码的大型软件包,而是一个神奇的示例。 - SChepurin
1
@sharptooth 谢谢你的添加。我并不是想暗示其他,只是引用了我使用的标准。 - Angew is no longer proud of SO
@Angew:是的,我理解,只是想说措辞有点老旧。 - sharptooth
1
@JimBalter 注意,我说的是“从正式意义上讲,不可能推理”,而不是“不可能正式推理”。你是对的,可以推理程序,但需要知道编译器的详细信息才能做到这一点。编译器完全有权利简单地消除对main()的调用,或者用API调用替换它来格式化硬盘,或者其他任何操作。 - Angew is no longer proud of SO
显示剩余8条评论

57

也许理解这段代码最简单的方法是逆向思考。我们从要打印的字符串开始 - 为了平衡,我们将使用"C++Rocks"。关键点:就像原始代码一样,它恰好有八个字符。因为我们打算(大致)按照原来的方式,以相反的顺序打印出来,所以我们会先将其倒序排列。首先,我们将把它视为一个double位模式,并打印出结果:

#include <stdio.h>

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

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

这将产生3823728713643449.5。因此,我们希望以一种不明显但易于反转的方式进行操作。我将选择乘以256(半随意),这将给我们978874550692723072。现在,我们只需要编写一些混淆的代码来除以256,然后倒序打印出该字节:

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

现在我们有很多转型,传递给(递归)main的参数完全被忽略(但是为了获取增量和减量而进行估值是非常关键的),当然还有那个看起来完全任意的数字来掩盖我们实际上正在做的事情非常简单。

当然,由于整个重点是混淆,如果我们愿意,我们还可以采取更多步骤。例如,我们可以利用短路求值将我们的if语句转换为单个表达式,这样main函数的主体如下:

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

对于那些不习惯混淆代码(和/或代码高尔夫)的人来说,这看起来确实很奇怪 - 计算并丢弃一些无意义浮点数与 main 的返回值的逻辑 and,而 main 甚至没有返回值。更糟糕的是,如果没有意识到(并思考)短路求值的工作原理,它甚至可能不会立即清楚地知道它如何避免无限递归。

我们下一步可能要做的是将打印每个字符与查找该字符分开。我们可以通过将正确的字符生成为 main 的返回值,并打印出 main 返回的内容来轻松实现:

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

至少对我而言,那看起来已经够混淆了,所以我就不多说了。


24

这只是构建一个双重数组(16字节),如果将其解释为char数组,则构建的是字符串“C++Sucks”的ASCII码。

然而,该代码在每个系统上都不起作用,它依赖于以下一些未定义的事实:


12
以下代码输出C++Suc;C,因此整个乘法仅适用于最后两个字母。
double m[] = {7709179928849219.0, 0};
printf("%s\n", (char *)m);

11

其他人已经相当详细地解释了这个问题,我想补充一点,根据标准,这是未定义行为

C++11 3.6.1/3 主函数

函数main不应在程序中使用。 main的连接(3.5)是实现定义的。 将main定义为已删除的程序或声明main为内联,静态或constexpr是不良形式的。 名称main没有其他保留意义。 [示例:可以调用成员函数、类和枚举,也可以调用其他命名空间中的实体。—end example]


1
我认为它甚至是不规范的(就像我在我的回答中所说的那样)- 它违反了一个“应该”。 - Angew is no longer proud of SO

9
代码可以重写成这样:
void f()
{
    if (m[1]-- != 0)
    {
        m[0] *= 2;
        f();
    } else {
          printf((char*)m);
    }
}

它所做的是在双精度数组“m”中生成一组字节,它们恰好对应于字符'C++Sucks'后跟一个空终止符。他们通过选择一个双精度值来混淆代码,该值在标准表示中被加倍771次,提供了由数组的第二个成员提供的空终止符的那组字节。
请注意,在不同的字节序表示下,此代码将无法正常工作。此外,调用“main()”并非严格允许。

1

首先,我们需要回忆一下,双精度浮点数以二进制格式存储在内存中,具体如下:

(i) 1位用于表示符号

(ii) 11位用于表示指数

(iii) 52位用于表示幅值

这些位的顺序从(i)到(iii)递减。

首先将十进制小数转换为等效的二进制小数,然后将其表示为二进制幂级数形式。

因此,数字7709179928849219.0变成了

(11011011000110111010101010011001010110010101101000011)base 2


=1.1011011000110111010101010011001010110010101101000011 * 2^52

现在考虑位数时,所有数量级方法都以“1。”开始,因此忽略位数为1。的位。因此,位数部分变为:
1011011000110111010101010011001010110010101101000011 

现在2的幂次方是52,我们需要添加偏置数到它上面,偏置数为2的指数位数-1次方减1,即2^(11-1)-1=1023,所以我们的指数变成了52+1023=1075。
现在我们的代码将数字乘以2,共771次,这使得指数增加了771。
因此我们的指数是(1075+771)=1846,其二进制等价于(11100110110)。
现在我们的数字为正,所以符号位为0。
因此我们的修改后的数字变成:符号位+指数+尾数(位的简单串联)。
0111001101101011011000110111010101010011001010110010101101000011 

由于m被转换为字符指针,因此我们应该从LSD将位模式分割成8个块。

01110011 01101011 01100011 01110101 01010011 00101011 00101011 01000011 

(其十六进制等价值为:)

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

ASCII CHART Which from the character map as shown is :

s   k   c   u      S      +   +   C 

现在,一旦m[1]被设置为0,这意味着一个空字符。
假设您在小端机器上运行此程序(低序位位于低地址),因此指针m指向最低地址位,然后按8个一组地取出位(强制类型转换为char*),当在最后一组遇到00000000时,printf()停止...
然而,此代码不具备可移植性。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接