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 时,_ ^ 448 为 0,短路求值跳过右侧递归调用,函数开始返回。
递归调用 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:
- 若不为
0(即当前列不是第 64 列),计算一个像素字符并输出; - 若为
0,则输出10,即换行符'\n'。
由于 _ 递减,--_ % 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 利用整数除法将像素位置映射到字符块:
- 8 个连续的
_值对应同一个块编号; - 递减过程中,块编号循环为
7,7,…,7, 6,6,…,6, …, 0,0,…,0(每个重复 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
_ * 2 & 8根据当前列在字符内的左右半区产生不同值:列偏移 0~3 时结果为 0,列偏移 4~7 时结果为 8。_ / 64为当前行号(0~6)。- 两者按位或得到一个 0~14 的索引,从字符串
";;;====~$::199"中取出一个字符作为“除数基准值”。 _ & 2 ? 1 : 8根据列偏移的奇偶分组决定真正的除数:列偏移为 2,3,6,7 时除数为 1,否则为 8。- 基准值除以该除数后再对 8 取模,得到一个 0~7 的移位量。
于是,像素值由下式决定:
(压缩字模 >> 移位量) & 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;
{ … }
- 函数返回类型缺省,编译器自动视为
int(C89 隐式int规则),C++ 禁止省略。 - 参数类型在函数体前声明,属于 K&R 旧式定义语法,C++ 完全不支持,必须写作
int main(int _, 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 编写,标题也是他自己写的