上周六在准备华为网络技术考试的闲暇之余发现了一段超级有趣的代码,出自国际C语言混乱代码大赛。或许你像我一样第一次听说这个比赛,那就摘一段维基百科的介绍:

国际C语言混乱代码大赛(IOCCC, The International Obfuscated C Code Contest)是一项国际程序设计赛事。从1984年开始,本赛事每年举办一次。本赛事的目的是写出最有创意和最让人难以理解的C语言代码。

先看一眼这段小巧玲珑的代码

main(_){_^448&&main(-~_);putchar(--_%64?32|-~7[__TIME__-_/8%8][">'txiZ^(~z?"-48]>>";;;====~$::199"[_*2&8|_/64]/(_&2?1:8)%8&1:10);}

运行这段代码看看干了啥

$ gcc ioccc.c -o a.out
$ ./a.out
    !!      !!              !!  !!!!!!          !!!!!!  !!!!
    !!  !!  !!              !!  !!  !!              !!  !!
    !!  !!  !!              !!  !!  !!              !!  !!
    !!  !!!!!!    !!        !!  !!  !!    !!      !!!!  !!!!
    !!      !!              !!  !!  !!              !!  !!  !!
    !!      !!              !!  !!  !!              !!  !!  !!
    !!      !!              !!  !!!!!!          !!!!!!  !!!!!!

这段代码居然能够将编译时刻系统时间(非运行程序时间)按照ASCII风格输出,太强了。

说实话,第一次看到这种操作的我感觉已经被秀了一脸了;等我仔细研究完这行代码之后的感觉是:太骚了,代码居然还能这么写!

接下来准备详细分析这一行小小的代码是如何实现如此炫酷复杂吊炸天的功能的。

全文极度烧脑预警

一些你可能从来没有考虑过的问题

何时可以不指明返回类型?

正常的main函数声明都是这样的

int main(int argc, char * arcgc[])
{

    return 0;
}

当然main函数的参数是可以省略的,可以单单写成main()return 0也是可以省略的,程序会正常终止,只不过会多一条warning。

在实际生产环境中当然不会出现这样的代码,这只是极客们利用c语言的细节追求极简的成果。

甚至,main前面的int声明都可以省略。

Stack Overflow上有讨论这个默认类型声明:

在K&R的经典教材C Programming Language的§6.5.2.1中列举了一些隐含的类型声明:int, signed, signed int 或是没有声明类型这四者是等价的。

所以main前面的int可以省略,参数列表里的int也可以省略,最后就会只剩下代码中的:

main(_){}

注意到_是一个变量名,处在原本argc的位置,经过试验发现,其功能就是记录传入的参数个数。当你想我一样使用$ ./a.out不加任何参数调用的时候,其值就是1。

  • argc is 1;
  • argv[0] is the string “./a.out”
  • argv[1] is a NULL pointer

何时可以不包含头文件?

putchar()函数可以不用include头文件。虽然它不是系统函数,但是实测确实可以。

这样我们可以愉快地把这段代码的格式排排好,把省略的声明补齐,顺便加上头函数。

预处理:变得能看

#include <stdio.h>

int main(int i){
    i^448&&main(-~i);
    putchar(--i%64?32|-~7[__TIME__-i/8%8][">'txiZ^(~z?"-48]>>";;;====~$::199"[i*2&8|i/64]/(i&2?1:8)%8&1:10);
    return 0;
}

利用&&的短路求值特性将第一行展开,^亦或运算符仅在i==448时为假,-~i将i取反之后取负数。由于int是使用补码在计算机内部存储的,取负数操作实际上等效于按位取反后加1,-~i等效于i+1。所以可以发现第一行就是一个条件递归。

int main(int i){
    if (i != 448) main(i+1);
    i--;
    putchar(i%64?32|-~7[__TIME__-i/8%8][">'txiZ^(~z?"-48]>>";;;====~$::199"[i*2&8|i/64]/(i&2?1:8)%8&1:10);
    return 0;
}

此时需要知道我们在第一次进入main函数的时候i的初始值是1(不加参数的话)。此时需要把之后会对i有操作的–i提取到putchar外面(不然没法转化),这句递归可以转化为循环(非常不直观,需要花点时间好好想想)。

int main(){
    for (int i=447; i>=0; i--) { //由于外层i--的操作,这里从447到0
        putchar(i%64?32|-~7[__TIME__-i/8%8][">'txiZ^(~z?"-48]>>";;;====~$::199"[i*2&8|i/64]/(i&2?1:8)%8&1:10);
    }
}

要注意i是从447减小到0的,一共执行448次。因为递归调用的时候是从main(1)一直到main(448)。i=448时不再继续递归,但该次main剩下的操作还是要进行。

接下来只有一句putchar了,看来一共会输出448个字符。最后的:10可以知道根据条件输出会有回车LF(ASCII:10),数一数最开始输出的图形的行数,我们发现有7行,可以猜测一下这个输出回车的条件应该会是与7的倍数有关。但是中间这一行putchar实在太长,我们一点一点看。首先看一看第一个?:运算符的条件

putchar(i%64?/*some value*/:10);

这时我们突然发现每行的字符448/7=64。于是我们知道了每64个字符会出现一个换行符。

把:?运算符写成if-else形式会更加直观

int main(){
    int i;
    for (i = 447; i >= 0; i--) {
        if (i % 64 == 0)
            putchar('\n');
        else {
            putchar(32 | -~7[__TIME__ - i / 8 % 8][">'txiZ^(~z?" - 48] >> ";;;====~$::199"[i * 2 & 8 | i / 64] / (i & 2 ? 1 : 8) % 8 & 1)
        }
    }
    return 0;
}

你看的精疲力尽了吗?哈哈我也写的累了。但是正真巧妙的东西还没有开始呢!

我们需要花非常大的力气去看看putchar里到底写了什么。

预处理:变得像人写的

来看这个输出,发现里面有两个字符串常量,还有移位运算和许多的位运算。这时候我不得不默默地掏出了好久没看的运算符优先级顺序表,给优先级加上括号。一句话就是算数运算优先,然后是移位运算,然后逻辑比较运算,最后位运算。

32|-~7[__TIME__-(i/8%8)][">'txiZ^(~z?"-48] >> ";;;====~$::199"[(i*2)&8|i/64]/(((i&2)?1:8)%8)&1

所以说上面这一行表达式的运算顺序是32|/*some value*/是最后算的,右边是(一个含有__TIME__宏的大东西 >> 一个字符串一样的东西) &1,这里可以先定义两个本地变量来使最后的结果显得更加清楚。

int main(){
    int i;
    for (i = 447; i >= 0; i--) {
        if (i % 64 == 0) putchar('\n');
        else {
            char a = -~7[__TIME__-(i/8%8)][">'txiZ^(~z?"-48];
            char b = ";;;====~$::199"[((i*2)&8)|(i/64)]/((i&2)?1:8)%8;
            putchar(32 | ((a >> b) & 1));
        }    
    }
    return 0;
}

然后来处理这一堆方括号,这其中可能又会触及到大家对c语法的一些盲区了。大家都知道a[1]这个表达式是取了数组a中的第二个元素,但是其实还有一种等价的写法是1[a],大多数人应该基本不会碰到这样的表达式,实在是太……无聊的写法,但是这也向我们揭示了一个关于[]运算符的一个本质,就是a[1] => *(a + 1),大家永远不应该忘记[]的实质是在一个指针上偏移一个量再去取出其中的数据。利用这一个知识点,把形如1[a]的表达式中方括号之前的东西的位置换到后面去并加上方括号,我们继续把上面的a和b展开。

前文已经提到过:-~i等效于i+1

int main(){
    int i;
    for (i = 447; i >= 0; i--) {
        if (i % 64 == 0) putchar('\n');
        else {
            char a = (">'txiZ^(~z?"-48)[(__TIME__-(i/8%8))[7]] + 1;
            char b = ";;;====~$::199"[((i*2)&8)|(i/64)]/((i&2)?1:8)%8;
            putchar(32 | ((a >> b) & 1));
        }    
    }
    return 0;
}

再引入一个变量t以简化,并且把-48-(i/8%8)这两个偏移量移动到后面的方括号内去,

char t = __TIME__[7 - i / 8 % 8];
char a = ">'txiZ^(~z?"[t - 48] + 1;

我们知道,在>>号右边的应该是一个数来表示位移量,于是我们再引入一个shift变量,并且根据优先级展开b变量,用c表示a>>b。注意到,(i&2)?1:8里还有一个条件判断,把他展开成一个分支语句。

int main(){
    int i;
    for (i = 447; i >= 0; i--) {
        if (i % 64 == 0) putchar('\n');
        else {
            char t = __TIME__[7 - i / 8 % 8];
            char a = ">'txiZ^(~z?"[t - 48] + 1;
            char shift = ";;;====~$::199"[(i*2)&8 | i/64];
            if ((i&2) == 0)
                shift /= 8;
            shift = shift % 8;
            char c = a >> shift;
            putchar(32 | c&1);
        }    
    }
    return 0;
}

写到这里我们先停一下,看上去已经比较像是正常的代码了(误),一些运算符的优先级我们也都已经展开完毕了,好像似乎无法进行简化了,但是程序的逻辑真的是一丝都没有任何的明朗。我们需要从头开始看看整体逻辑。for循环一共执行448次,每64次会换行,这样就会输出7行。不换行的时候会进行一堆判断,最后输出的关键语句是putchar(32 | c&1),稍微看一下这个逻辑就会发现,32 | c&1只有两个值,要么是32(ASCII 空格)或是33(ASCII !)。这样会给我们真正地深入分析提供思路。

处理:理清逻辑

    !!  !!!!!!          !!!!!!  !!!!!!              !!  !!!!!!
    !!      !!          !!  !!      !!          !!  !!  !!  !!
    !!      !!          !!  !!      !!          !!  !!  !!  !!
    !!      !!    !!    !!  !!    !!!!    !!    !!!!!!  !!!!!!
    !!      !!          !!  !!  !!                  !!      !!
    !!      !!          !!  !!  !!                  !!      !!
    !!      !!          !!!!!!  !!!!                !!  !!!!!!

再次整理一下我们已经了解的一些信息:

  • 最终输出一共有7行,每行64个字符。不知大家是否对64和代码中出现了很多次的8这两个数字有些许感觉,其实在这里结合我们观察到的图形,我们已经可以大胆地假设:$64=8\times 8$。有人会想这还要你假设,这不是事实吗?我的意思是一行的64个字符可以被均分为8组,每组8个字符。写到这里你再看看上图就知道:冒号其实和数字是同等的地位,这是后话了。这样的话我们就可以把这个$7\times 64$矩形分割成为八个$7\times 8$的小矩形,分别对应小时的两位数字、冒号、分钟的两位数字、冒号秒的两位数字。

这样的话我们就有足够的insight可以着眼于shift这个变量了。

shift 移位变量初探

char shift = ";;;====~$::199"[ (i*2)&8 | i/64];
if ((i&2) == 0)
    shift /= 8;
shift = shift % 8;
  • i/64这是两个整型做除法,所以得到向下取整的整数。不难想象这可以表示行号,在0到6之间,用3个bit就可以表示。
  • (i*2)&8涉及到了位运算。(i*2)等价于i左移1位,(i*2)&8等价于检测原本i的倒数第3位的情况,只要i = 4, 5, 6, 7 mod 8,这位就是1。
  • (i*2)&8 | i/64前半部分的结果除了倒数第4位有可能有值,其他位全为0;后半部分只有3个bit,与在一起正好是一个4 bit的数。相当于x000|0yyy-->xyyy
  • 当i = 0, 1, 4, 5 mod 8时,i&2为0,进入if分支,shift将会左移三位。
  • 最后如论如何,shift mod 8后被映射到了0-7上,那么shift就是一个0-7的数。

string1 居然是……

到了这个时候你可能会想了,这个shift居然是一个和行号有关的量,我们完全有理由相信shift就是一个用来表示位置的量。这时候我们再回过神来看前面的那个含宏的变量a。在这之前,补充一个必须的知识点,就是__TIME__这个宏是长啥样的。__TIME__会返回一个字符串,用printf("%s", __TIME__)会得如下的结果:19:22:18,形如"HH:MM:SS"。

char t = __TIME__[7 - i / 8 % 8];
char a = ">'txiZ^(~z?"[t - 48] + 1;

又是出现了好多的8啊!但是这回你应该可以自己分析出来了,i / 8 % 8得到的是当前位置我应该打印的是什么字符($7\times 8$的小矩形),用7减是因为i是从448开始减小的,这样随着i的减小,7 - i / 8 % 8可以按照从0到7的顺序反复遍历了。注意到十进制的48在ascii码表中对应的是数字0哦。那样的话,">'txiZ^(~z?"[t - 48]就相当于根据这一位该显示的数字在字符串">'txiZ^(~z?"中做选择。

看到这一步,我们该是不知道这个字符串是干嘛用的,但是大家不用慌,我们已经知道t - 48只能在0-9和10(冒号)中取值,我这就给大家来个枚举法,看看到底是什么。

顺便一提,为什么一定要写成">'txiZ^(~z?"[t - 48] + 1这种+1的形式而不直接写"另一个字符串"[t-48]呢?这是因为~再加1就不是可打印字符了哦,真是挺凑巧的。

0->10在字符串中做选择后的值的二进制表示如下:

0 00111111
1 00101000
2 01110101
3 01111001
4 01101010
5 01011011
6 01011111
7 00101001
8 01111111
9 01111011
: 01000000

请大家在往下翻之前好好的看几眼这个表,看看每个二进制的第一位,特别是8这个数字所对应的二进制,再看看别的二进制。你想到了什么?

……

现在就是拿出你数字电路水平的时候了。其实这tm就是个7段数码管!你可能会想,这我怎么看的出来啊!没事,回过头来看shift你就完全能感受到了。

shift 移位变量深入

char shift = ";;;====~$::199"[ (i*2)&8 | i/64];
if ((i&2) == 0)
    shift /= 8;
shift = shift % 8;

这个if条件的除8后模8是不是有点眼熟啊,__TIME__[7 - i / 8 % 8]中就已经出现过了,不过这里的意思是

  • 当i = 0, 1, 4, 5 mod 8时,i&2为0,进入if分支,shift将会右移三位后取剩下的最低三位。
  • 当i = 2, 3, 6, 7 mod 8时,shift直接取剩下的最低三位。

i/64表示行号,(i*2)&8表示在小剧情的左半边还是右半边。

话不多说,打表!

00005577
11775577
11775577
11665577
22773377
22773377
44443377

让我们对着表来理解 ";;;====~$::199"[ (i*2)&8 | i/64],方括号内右边表示我(i)在第几行,左边表示我在这一行的左边还是右边。再次注意到这里的i是7-实际的行号(i是从448开始减小的,而putchar是从左上方到右下方的)。

  • 在左半边:(i*2)&8==1时就在"$::199"中选择(注意字符串结尾还有一个结束标志\0,行号从0到6,所以有7个选择)。
  • 如果在右半边:(i*2)&8==0时就在";;;====~“中做选择。这样就和前面一样有7个选择了。其实我们马上可以意识到最后的~字符完全是用来凑数的,可以换成任何的可打印字符,因为永远用不到(只有7行0->6,数不到7)。

7段数码管原理

再简化一点,$7\times 64$矩形被分割成为八个$7\times 8$的小矩形,每个小矩形按照模8的等价关系再被分为每一行和每一行的左半边和右半边。由于行号是从6减小到0,所以说选择的时候是从”$::199"和";;;====~“中逆着选的。"$::199"对应行竖着下来"0111224”,";;;====“对应着行竖着下来"5555333”,为什么左右半边又被分了呢?因为还有if条件的移位啊:

  • 当i = 0, 1, 4, 5 mod 8时,shift将会右移三位后取剩下的最低三位。
  • 当i = 2, 3, 6, 7 mod 8时,shift直接取剩下的最低三位。

example-visual

也就是说,当在图中红框内时,属于i = 2, 3, 6, 7 mod 8时(再次提醒i是减小的),直接取低3位;别的位置时,取高三位。

这就很有意思了,看到有一列全是7,是在右边的情况,高位为111,可以验证;:的高三位全1

111

给大家贴一张ASCII码表

写到这里,大家都明白是怎么一会事了吧。再一次重新看完整的代码。

int main(){
    int i;
    for (i = 447; i >= 0; i--) {
        if (i % 64 == 0) putchar('\n');
        else {
            char t = __TIME__[7 - i / 8 % 8];
            char a = ">'txiZ^(~z?"[t - 48] + 1;
            char shift = ";;;====~$::199"[(i*2)&8 | i/64];
            if ((i&2) == 0)
                shift /= 8;
            shift = shift % 8;
            char c = a >> shift;
            putchar(32 | c&1);
        }    
    }
    return 0;
}

使用单维度i来标志当前打印字符的位置,8位字符a表示这一个区域内:$7\times 8$的小矩形内该显示的数字的七段数码管各管脚的高低电平。shift移位后用来确定当前bit是否应该被点亮。

大家有部分没有看懂的话没关系的,只需要知道原理就是7段数码管的原理,两个字符串只不过是构造这个7段数码管。

当然,这里面的思想更加深邃。

后记

从详细地分析这一行代码的过程中我们得到了些什么呢?

  • 知道了一行代码到底能干什么事
  • 知道了看别人写的代码时是什么感受
  • 知道了c还有这么多我不知道的特性
  • 知道了一个道理:想的越多,写的越少
  • 知道了条条大路通底层电子技术原理

对比

main(_){_^448&&main(-~_);putchar(--_%64?32|-~7[__TIME__-_/8%8][">'txiZ^(~z?"-48]>>";;;====~$::199"[_*2&8|_/64]/(_&2?1:8)%8&1:10);}

vs

int main(){
    int i;
    for (i = 447; i >= 0; i--) {
        if (i % 64 == 0) putchar('\n');
        else {
            char line_num_inv = i / 64;
            char section_num_inv = i / 8 % 8;
            char section_num = 7 - section_num_inv;
            
            char t = __TIME__[section_num];
            char a = ">'txiZ^(~z?"[t - 48] + 1;
            
            char shift = ";;;====!$::199"[(i * 2) & 8 | line_num_inv];
            if ((i&2) == 0)
                shift /= 8;
            shift = shift % 8;
            char c = a >> shift;
            putchar(32 | c&1);
        }    
    }
    return 0;
}

do the same thing

Acknowledgement

Stack Overflow - Obfuscated C Code Contest 2006. Please explain sykes2.c