IOCCC的混淆技巧小全 / 如何让luogu"未识别到人类代码"

· · 科技·工程

偶然听说 IOCCC,第一眼:?第二眼:%.txt
情不自禁想写一篇文章歌颂一下头发的壮烈史诗,于是选择了一个还算经典的例子:

2006/sykes2

Show(以下代码仅能在C89 或 K&R C中无警告运行)

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

用一个 64 * 7 的点阵图显示当时的时间

可能认为,这不就是把空格和 Tab 删了吗,但是(Formatted 格式化后):

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

Explanation(C++ / Present)

由于是很古早的代码了,不一定全部能在现在的编译器上通过,因此专门分块讲解,以下部分 C++ 都能用:
那么接下来开始逐字节分析吧!

1. -~_ 替换 _+1

从左到右,这三朵奇葩分别是:取负数(取补码)、按位取反、argc。_ 或者 argc 暂时理解为变量。取负数其实就是按位取反再加一,或者说取补码,此事在 lowbit 中也略有提及。按位取反应该很好理解。因此用汉字表达就是:对 _ 按位取反后再按位取反再加一,也就相当于 _ + 1。那么同样的,也有 ~-_ = _-1(如果 LaTeX 炸了请忽略)。如果看不懂,那么请看补码公式:~x = -x - 1,推理即可。

2. a[b]b[a] 互换

在 C 语言中,a[b] 总是等价于 *(a + b),因此这使得 a[b]b[a] 雌雄同体。原代码里有很多这样的例子: 7[__TIME__ - _/8%8],实际等价于 (__TIME__ - _/8%8)[7],进而化简为 __TIME__[7 - _/8%8]。这种把数组名和下标互换位置的手段,极大降低了代码的可读性,却是完全合法的。

3. _ ^ 448 && main(-~_, v)

先看最外层的控制逻辑:

_ ^ 448 && main(-~_, v);
putchar(…);

_main 的第一个参数,在无命令行参数运行时值为 1(即 argc)。_ ^ 448 是按位异或运算:当 _ 不等于 448 时,_ ^ 448 非零,&& 右侧的 main(-~_, v) 被执行;当 _ 等于 448 时,_ ^ 4480,短路求值跳过右侧递归调用,函数开始返回。

递归调用 main(-~_, v) 中的 -~_ 也就是 _ + 1,因此每次递归都将 _ 增加 1。整个过程等价于:

void f(int n) {
    if (n != 448) f(n + 1);
    putchar(…);
}
f(1);

当递归层层深入、_1 逐渐增加到 448 之后,最深层调用(_ == 448)不再递归,开始执行 putchar,随后返回上一层(_ == 447)执行 putchar,再返回 _ == 446……直至最初的 _ == 1 也返回。关键点在于每次 putchar 使用的是 --_,即先将 _1 再输出。因此最深层 _ == 448 时,--_ 得到 447 并输出;上一层 _ == 447 时,--_ 得到 446 并输出……最终输出序列即为 447, 446, …, 0恰好 448 个值,覆盖了从 447 到 0 的所有整数。

此处应特别注意:递归边界是 _ == 448,而输出从 447 开始,是因为 --_ 在每次输出前执行。这保证了输出序列总长度为 448(447 到 0 共 448 个数),并恰好对应 7 行 × 64 列的像素网格。

4. 行与列的划分

putchar(--_ % 64 ? … : 10);

条件表达式判断 --_ % 64 是否为 0

由于 _ 递减,--_ % 64 == 0 当且仅当 --_ 的值为 384, 320, 256, 192, 128, 64, 0 这七个值。因此,在连续的 448 次输出中,会精确插入 7 个换行符,将输出天然分隔为 7 行。每行包含 63 个像素字符和 1 个换行,在终端上形成 64 列宽的显示区域(最右侧为空白列,直接被换行取代)。也就是说,实际可见的像素网格为 7 行 × 63 列,加上被换行隐去的第 64 列空白

5. 时间字符的提取

时间字符串 __TIME__ 在编译时展开为 "HH:MM:SS"(8 个字符)。程序需要将这 8 个字符从左到右依次绘制,每个字符占据 8 列像素。

(_ / 8) % 8 利用整数除法将像素位置映射到字符块:

随后,7[__TIME__ - _/8%8] 这个看似反人类的写法等价于 (__TIME__)[7 - (_/8%8)]。(见上文)

因此,当块编号为 b 时,实际访问的字符索引为 7 - b。递减时 b 依次为 7,6,…,0,对应的索引 7-b 即为 0,1,…,7,正好按 HH:MM:SS 的正常顺序取出每个字符。该逻辑每行重复一次,保证七行输出均从左到右扫描整个时间字符串。

6. 字符到压缩字模的映射

取出时间字符后,下一步是获取该字符的 7×8 点阵字模。作者将所有 11 个可能字符(数字 0~9 和冒号)的字模高度压缩为一个 8 位整数,存储在映射字符串中:

">'txiZ^(~z?"[__TIME__[7 - _/8%8] - 48]

__TIME__[索引] - 48 将字符映射为整数偏移:'0'→0,'1'→1,…,'9'→9,':'(ASCII 58)→10。该偏移量作为索引从字符串 ">'txiZ^(~z?" 中取出一个字符,再用 -~(即加一)转换为 40~127 之间的整数,这就是该字符的压缩字模

各字符对应的原始字符与加一后的值如下表:

字符 索引 映射字符 ASCII +1 后的值
'0' 0 '>' 62 63
'1' 1 ''' 39 40
'2' 2 't' 116 117
'3' 3 'x' 120 121
'4' 4 'i' 105 106
'5' 5 'Z' 90 91
'6' 6 '^' 94 95
'7' 7 '(' 40 41
'8' 8 '~' 126 127
'9' 9 'z' 122 123
':' 10 '?' 63 64

该整数的每个比特位记录一个像素点的亮灭(1 为亮,0 为灭),通过不同移位组合即可解码出完整的 7×8 点阵。

7. 移位量的计算与像素提取

要从压缩字模中取出当前行列对应的像素,需要知道应当右移多少位。移位量由另一张压缩表提供:

";;;====~$::199"[_*2 & 8 | _ / 64]  /  (_ & 2 ? 1 : 8)  % 8

于是,像素值由下式决定:

(压缩字模 >> 移位量) & 1

该值为 1 时当前像素点亮,为 0 时不亮。

8. 像素输出与最终画面

最后一步:

32 | (像素值)

32 是空格字符 ' ' 的 ASCII 码,33'!'。二者按位或相当于:像素为 0 则输出空格,像素为 1 则输出 '!'putchar 将字符逐个送入标准输出,配合每 64 列一个的换行,在终端上绘制出完整的 7 行 ASCII 数字艺术。

Explanation(C / 建议跳过)

上述所有手法均在 C++ 中合法,但原代码还依赖一些仅 C(且多为 C89)才支持的特性,使其无法直接在标准 C++ 下编译。以下逐条说明。

1. 隐式 int 与 K&R 函数定义

main(_, v)
char ** v;
{ … }

2. 隐式函数声明

代码调用 putchar 而未包含 <stdio.h>。C89 允许对未声明的函数隐式声明为 int putchar(int),实际行为碰巧正确;C99 开始已不允许,C++ 则从最初即禁止。欲在较新的 C 或 C++ 中编译,必须 #include <stdio.h>

3. 递归调用 main

C99 及以后的标准、以及所有版本的 C++,均明确禁止在程序内部直接或间接调用 main。然而 C89 无此限制,因此原程序可以使用递归 main 来实现循环输出。这是该段代码“古董感”的直接来源之一。

4. 参数 _ 默认为 argc 的依赖

原程序依赖无命令行参数时 argc == 1,从而从 1 开始递归至 448。若用户传入参数,_ 的初值将不再为 1,输出会发生严重错乱。这种假设隐含了对程序调用方式的严格限制,在正式的健壮代码中自然应避免,但作为混淆技巧则不失“巧妙”。

5. 对 __TIME__ 的依赖

__TIME__ 是预定义宏,在 C 和 C++ 中都存在,用于获取编译时刻。本不算“独属于 C”,但需注意:若代码经过预编译或编译器未正确定义该宏,行为将未定义。不过,就依赖编译期字符串这一点而言,C 和 C++ 是共通的。

以上特性共同造就了这段代码的“纯 C89 血统”。将它们剥离后,便得到了前文 Explanation(C++/Present) 部分中完全等价的现代写法。

改写后的代码(C)

希 C++,使 std(3k站长 md炸了)

#include <stdio.h>

int main(void) {
    /* 时间字符 → 字模压缩值的映射表(原 -~ 已经 +1) */
    const char *font_map = ">'txiZ^(~z?";
    /* 移位除数表 */
    const char *shift_table = ";;;====~$::199";

    /*
     * 原程序递归后回溯输出,顺序是 _ 从 447 递减到 0。
     * 这里直接用递减循环完全模拟。
     */
    for (int idx = 447; idx >= 0; idx--) {
        /* idx % 64 == 0 时输出换行 */
        if (idx % 64 == 0) {
            putchar('\n');
            continue;
        }

        /* 1. 当前像素属于第几个时间字符(0~7) */
        int char_index = (idx / 8) % 8;
        /* 原代码:7[__TIME__ - char_index] → __TIME__[7 - char_index] */
        char time_char = __TIME__[7 - char_index];

        /* 2. 映射到 font_map 索引,':' 特殊处理 */
        int map_index = (time_char == ':') ? 10 : (time_char - '0');

        /* 3. 取出压缩字模(原 -~ 等价于 +1) */
        unsigned char font = font_map[map_index] + 1;

        /* 4. 计算移位除数表索引 */
        int shift_index = (idx * 2 & 8) | (idx / 64);
        unsigned char table_val = shift_table[shift_index];

        /* 5. 除数由 idx 的第 1 位决定(列内奇偶分组) */
        int divisor = (idx & 2) ? 1 : 8;

        /* 6. 得到移位量并提取像素 */
        int shift = (table_val / divisor) % 8;
        int bit = (font >> shift) & 1;

        /* 7. 输出 '!' 或 ' ' */
        putchar(32 | bit);   /* 32=' ', 33='!' */
    }

    return 0;
}

至此,一段极尽压缩与混淆之能事的 C 代码被逐层拆解完毕。或许在感叹其精妙的同时,也该向无数为此凋零的头发致以深深敬意。

Explanation(C) 及以后使用 Deepseek 编写,标题也是他自己写的