简介:面向CTF参赛者与信息安全学习者的离线解码工具包,聚焦Brainfuck、Ook及Short Ook三类深奥编码的本地解析需求。在无法接入外网的比赛或实验环境中,可直接调用脚本完成解码,避免依赖在线服务带来的不便与风险。压缩包共5个文件,以py脚本为核心执行入口,辅以txt文本存放待解码样例与说明,另附png示意图辅助理解编码结构,整体仅13KB,轻量易携带。已有7976人学习下载,说明其在CTF解题场景中具备一定实用价值。脚本在开源项目基础上做了优化,覆盖Brainfuck与Ook的常见变体,读者可直接获得可运行的解码程序、配套样例文本与结构示意,便于快速验证编码规则、对照调试输出结果,也可作为理解深奥语言解析逻辑的参考材料,适合需要离线解题与编码学习的中初级选手使用。
1. 从一段只有八个符号的代码说起:Brainfuck / OoK 解码脚本能干什么
第一次见到 Brainfuck 代码的人,多半会以为文件损坏了——满屏只有+ - < > [ ] . ,八个字符,像乱码一样堆在一起。而 OoK 更离谱,把同样的逻辑换成了Ook. Ook? Ook!三种组合,看起来像两只猩猩在对话。这两个语言都属于「深奥编程语言」(esolang),图灵完备但极难阅读。这份 Brainfuck / OoK 解码脚本要解决的,就是把这个黑匣子打开:把源码解析成可执行的指令序列,再逐条解释器执行,最终输出结果。它适合两类人——想搞懂解释器原理的初学者,以及需要批量处理或转换这类源码的开发者。用 Python 写,代码量不大,但五脏俱全,是理解「词法分析 → 语法映射 → 执行引擎」这条链路的好素材。
2. 解码脚本的架构拆解:从字符流到执行引擎的三层结构
这个项目的核心思路很清晰:不管输入是 Brainfuck 还是 OoK,最终都要归一化成同一套指令集,再交给同一个执行引擎。整个脚本可以拆成三层——输入预处理层、指令映射层、执行引擎层。这种分层不是硬凑的,而是因为两种语言的语法差异只存在于「表面符号」,底层语义完全一致,所以把差异隔离在映射层是最自然的做法。
2.1 目录结构与模块职责
一个典型的实现会长成这样,文件不多,但每个都有明确归属:
brainfuck_ook_decoder/ ├── main.py # 入口,命令行参数解析 ├── decoder.py # 核心解码逻辑:字符流 → 指令列表 ├── interpreter.py # 执行引擎:指令列表 → 输出 ├── mappings.py # Brainfuck 与 OoK 的符号映射表 └── samples/ ├── hello.bf # Brainfuck 示例 └── hello.ook # OoK 示例mappings.py是整个项目的「字典」,把两种语言的符号统一映射到八个内部操作码。decoder.py负责把原始文本切成有意义的 token 并转成操作码列表。interpreter.py只认操作码,不关心原始语言是什么。main.py做胶水,读文件、判断语言类型、调解码器、调执行器、打印结果。这种拆法的好处是:以后要加第三种 esolang(比如 Malbolge 的简化版),只需要在mappings.py加一张表,解码器和执行器一行不用改。
2.2 符号映射表:两种语言如何归一化
Brainfuck 的八个符号语义是固定的,OoK 则是每两个 token 组合对应一个 Brainfuck 符号。映射表通常写成字典:
# mappings.py # Brainfuck 八个符号到内部操作码的映射 BF_MAP = { '>': 'PTR_INC', # 指针右移 '<': 'PTR_DEC', # 指针左移 '+': 'VAL_INC', # 当前单元值加一 '-': 'VAL_DEC', # 当前单元值减一 '.': 'OUTPUT', # 输出当前单元字符 ',': 'INPUT', # 读入一个字符到当前单元 '[': 'LOOP_START',# 循环开始,当前单元为 0 则跳转到匹配的 ] ']': 'LOOP_END', # 循环结束,当前单元非 0 则跳回匹配的 [ } # OoK 的 token 对到 Brainfuck 符号的映射 OOK_MAP = { 'Ook. Ook?': '>', 'Ook? Ook.': '<', 'Ook. Ook.': '+', 'Ook! Ook!': '-', 'Ook! Ook.': '.', 'Ook. Ook!': ',', 'Ook! Ook?': '[', 'Ook? Ook!': ']', }这里有个细节值得说:OoK 的 token 对是「前一个词 + 后一个词」的组合,解析时必须按顺序两两配对,不能单独看某一个词。常见做法是先用正则把Ook[.?!]全部提取出来,再按每两个一组去查OOK_MAP。如果提取出的 token 数是奇数,说明源码不完整,直接报错比默默忽略更靠谱。
2.3 解码器:字符流到操作码列表
解码器的职责是把原始文本变成干净的['PTR_INC', 'VAL_INC', ...]列表。Brainfuck 里非八符号的字符(空格、换行、注释)要全部过滤掉;OoK 则要先 token 化再配对。两种语言走不同分支,但出口是同一个操作码列表:
# decoder.py import re from mappings import BF_MAP, OOK_MAP def decode_brainfuck(source: str) -> list: """过滤非指令字符,转成操作码列表""" return [BF_MAP[ch] for ch in source if ch in BF_MAP] def decode_ook(source: str) -> list: """提取 Ook token,两两配对后转成操作码列表""" tokens = re.findall(r'Ook[.?!]', source) if len(tokens) % 2 != 0: raise ValueError(f'OoK token 数量为奇数({len(tokens)}),源码可能不完整') ops = [] for i in range(0, len(tokens), 2): pair = f'{tokens[i]} {tokens[i+1]}' if pair not in OOK_MAP: raise ValueError(f'无法识别的 OoK 组合: {pair}') ops.append(BF_MAP[OOK_MAP[pair]]) return opsdecode_brainfuck用列表推导一行搞定,因为 Brainfuck 的符号本身就是单字符,过滤即解码。decode_ook多了一步配对,re.findall的 patternOok[.?!]能精确匹配三种 token,不会把Ook后面跟别的字符误抓进来。配对时用 f-string 拼成'Ook. Ook?'这种格式去查表,和OOK_MAP的键保持一致。两个函数都返回操作码列表,后续执行器完全无感。
2.4 执行引擎:指针、纸带与循环跳转
执行引擎是项目里最需要小心的地方。Brainfuck 的运行时模型很简单:一条纸带(通常 30000 个字节单元)、一个指针、一个指令指针。难点在[和]的跳转——遇到[时如果当前单元为 0,要跳到匹配的]之后;遇到]时如果当前单元非 0,要跳回匹配的[之后。常见做法是预扫描一遍操作码列表,用栈建立括号配对表,执行时 O(1) 查表跳转,避免每次循环都重新扫描:
# interpreter.py def build_jump_table(ops: list) -> dict: """预扫描,建立 [ 和 ] 的配对索引""" stack, table = [], {} for idx, op in enumerate(ops): if op == 'LOOP_START': stack.append(idx) elif op == 'LOOP_END': if not stack: raise SyntaxError(f'位置 {idx} 的 ] 没有匹配的 [') start = stack.pop() table[start] = idx table[idx] = start if stack: raise SyntaxError(f'位置 {stack[-1]} 的 [ 没有匹配的 ]') return table def execute(ops: list, tape_size: int = 30000) -> str: tape = [0] * tape_size ptr = 0 ip = 0 output = [] jump = build_jump_table(ops) while ip < len(ops): op = ops[ip] if op == 'PTR_INC': ptr += 1 if ptr >= tape_size: raise IndexError('指针越界:超出纸带右端') elif op == 'PTR_DEC': ptr -= 1 if ptr < 0: raise IndexError('指针越界:超出纸带左端') elif op == 'VAL_INC': tape[ptr] = (tape[ptr] + 1) % 256 elif op == 'VAL_DEC': tape[ptr] = (tape[ptr] - 1) % 256 elif op == 'OUTPUT': output.append(chr(tape[ptr])) elif op == 'INPUT': tape[ptr] = ord(input()[0]) if True else 0 elif op == 'LOOP_START': if tape[ptr] == 0: ip = jump[ip] elif op == 'LOOP_END': if tape[ptr] != 0: ip = jump[ip] ip += 1 return ''.join(output)build_jump_table用栈做括号匹配,这是编译原理里的经典手法,顺手还能检测不配对的括号——比执行到一半才崩掉友好得多。execute里纸带默认 30000 格,和多数 Brainfuck 实现的约定一致;VAL_INC和VAL_DEC用% 256保证单元值始终在 0–255 之间回绕,避免 Python 整数无限增长导致输出chr()报错。指针越界直接抛异常而不是静默扩展纸带,是因为越界往往意味着源码逻辑有问题,早点暴露比藏着好。INPUT那行是简化写法,实际项目里会做成可注入的输入流,方便测试时喂固定数据。
3. 把脚本跑起来:命令行入口与三种典型用法
代码写完了,得能跑才算数。这个项目的入口设计成命令行工具,支持直接传文件路径,也支持从标准输入读,方便管道组合。下面按「装环境 → 跑示例 → 验证输出」的顺序走一遍。
3.1 环境准备与依赖
项目只依赖 Python 标准库,re用于 OoK 的 token 提取,sys和argparse用于命令行参数。没有第三方包,意味着不需要虚拟环境也能跑,但建议还是建一个,避免和系统 Python 的包混在一起:
# 确认 Python 版本,3.8 以上都行 python3 --version # 建虚拟环境(可选但推荐) python3 -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate # 不需要 pip install,直接跑 python3 main.py --help--help能正常打印用法说明,就说明模块导入路径没问题。如果报ModuleNotFoundError: No module named 'mappings',多半是没在项目根目录下执行,或者main.py里的导入写成了包内相对导入但没加__init__.py。最省事的做法是始终在项目根目录运行,导入用平铺的from decoder import decode_brainfuck。
3.2 跑通第一个 Brainfuck 示例
samples/hello.bf是一个打印Hello World!的经典 Brainfuck 程序。入口脚本根据文件扩展名判断语言类型,.bf走 Brainfuck 分支,.ook走 OoK 分支:
# main.py import argparse from decoder import decode_brainfuck, decode_ook from interpreter import execute def main(): parser = argparse.ArgumentParser(description='Brainfuck / OoK 解码执行器') parser.add_argument('file', help='源码文件路径') parser.add_argument('--tape-size', type=int, default=30000, help='纸带单元数量,默认 30000') args = parser.parse_args() with open(args.file, 'r', encoding='utf-8') as f: source = f.read() if args.file.endswith('.ook'): ops = decode_ook(source) else: ops = decode_brainfuck(source) print(f'[解码] 共 {len(ops)} 条操作码') result = execute(ops, tape_size=args.tape_size) print(f'[输出] {result}') if __name__ == '__main__': main()运行命令和预期输出:
python3 main.py samples/hello.bf # [解码] 共 111 条操作码 # [输出] Hello World!--tape-size参数是给那些需要大纸带的程序留的口子,默认 30000 覆盖绝大多数场景。打印操作码数量是个实用的小设计——如果解码结果明显偏少,比如只有个位数,基本可以判断源码里混进了不该有的字符或者文件读错了。
3.3 OoK 源码的转换与执行
OoK 示例跑起来和 Brainfuck 没区别,只是解码分支不同:
python3 main.py samples/hello.ook # [解码] 共 111 条操作码 # [输出] Hello World!如果手头只有 Brainfuck 源码,想转成 OoK 格式做演示,可以加一个反向转换的小工具。常见做法是在mappings.py里把OOK_MAP反转一下,再按操作码查回 OoK token 对:
# 反向转换:操作码列表 → OoK 源码 REVERSE_OOK = {v: k for k, v in OOK_MAP.items()} BF_TO_OOK = {BF_MAP[bf]: ook for bf, ook in REVERSE_OOK.items()} def to_ook(ops: list) -> str: return ' '.join(BF_TO_OOK[op] for op in ops)这段代码不在主流程里,但二次开发时很实用——比如做教学演示,同一段逻辑用两种语法各跑一遍,对比输出是否一致,能直观说明「表面符号不同、底层语义相同」。注意BF_TO_OOK的构造顺序:先反转OOK_MAP得到「Brainfuck 符号 → OoK 组合」,再借BF_MAP把操作码映射过去,两步都依赖字典推导,写的时候别把方向搞反。
4. 避坑与排查:解码执行时最容易翻车的五个地方
这个项目代码量不大,但 esolang 的坑往往藏在细节里。下面五条是我在实际调试中反复遇到的,每条按「现象 → 原因 → 解决」记下来,照着排查能省不少时间。
4.1 输出乱码或空字符串
现象:程序跑完不报错,但输出是空串或者一堆不可见字符。原因通常是纸带单元值超出了 0–255 范围,chr()拿到非法码点后行为不可预期;或者VAL_INC/VAL_DEC没做取模,Python 整数一路涨到几万,chr()直接抛ValueError。解决:确认加减操作都套了% 256,输出前可以加一句assert 0 <= tape[ptr] <= 255兜底。另一个可能是源码里混入了全角符号,比如中文输入法下的+,BF_MAP查不到就被静默过滤了,解码出的操作码数量会偏少。
4.2 循环跳转死循环
现象:执行卡住不动,CPU 占满。原因多半是build_jump_table的配对逻辑有误,比如LOOP_END时栈为空却没报错,导致jump表里缺键,执行到ip = jump[ip]时抛KeyError被外层吞掉,或者跳转目标算错一位,在[和]之间反复横跳。解决:先单独跑build_jump_table,打印配对表检查每个LOOP_START的索引是否小于对应的LOOP_END;再在execute里加一个指令计数器,超过某个阈值(比如一千万条)就强制中断并打印当前ip和ptr,定位卡在哪个循环。
4.3 OoK 源码解析出奇数个 token
现象:decode_ook抛ValueError: OoK token 数量为奇数。原因有两种:源码文件末尾被截断,或者文件里混入了Ook后面跟空格再跟标点的变体(比如Ook .),正则Ook[.?!]匹配不到,导致 token 总数少了一个。解决:先用grep -o 'Ook[.?!]' file.ook | wc -l数一下实际 token 数,和文件里Ook出现次数对比;如果是格式变体,把正则放宽成Ook\s*[.?!]再在配对时归一化空格。别小看这个,手写 OoK 源码时多敲一个空格太常见了。
4.4 指针越界但没报错
现象:程序输出了部分正确结果后突然变成乱码。原因是指针减到了 -1,Python 的负索引让tape[-1]悄悄访问了纸带末尾的单元,逻辑完全错位却不抛异常。解决:PTR_DEC分支里必须显式判断ptr < 0并抛IndexError,不能依赖 Python 的负索引行为。同理PTR_INC也要判断上界。这类 bug 最阴险的地方在于「有时候能跑对」,因为负索引恰好指向了一个值为 0 的单元,看起来像正常结束。
4.5 大纸带程序性能骤降
现象:某些程序在默认 30000 格纸带上跑得飞快,换成--tape-size 1000000后明显变慢。原因是tape = [0] * tape_size每次执行都重新分配大列表,而且execute里逐条if/elif分派在指令量大时开销不小。解决:纸带用bytearray(tape_size)替代列表,内存更紧凑、初始化更快;指令分派可以预先把操作码转成整数常量,用列表索引代替字符串比较。不过对教学用途来说,可读性优先,性能优化留到真正需要跑大程序时再做。
5. 二次开发:把解码器改造成可复用的 esolang 工具链
拿到这份脚本后,最自然的改造方向是把它从「能跑」推到「好用」。我在类似项目里踩过的血泪经验是:一开始图省事把所有逻辑塞进一个文件,等到要加第三种语言时才发现解码器和执行器耦合太紧,改一处崩三处。所以二次开发的第一步不是加功能,而是把边界划清楚。
5.1 用插件式映射表支持新语言
现在的mappings.py是硬编码的两张表。更灵活的做法是定义一个统一的语言描述结构,每种语言一个模块,主流程按扩展名动态加载:
# languages/__init__.py LANG_REGISTRY = {} def register(ext: str): def wrapper(cls): LANG_REGISTRY[ext] = cls return cls return wrapper # languages/brainfuck.py from . import register @register('.bf') class Brainfuck: SYMBOLS = {'>': 'PTR_INC', '<': 'PTR_DEC', '+': 'VAL_INC', '-': 'VAL_DEC', '.': 'OUTPUT', ',': 'INPUT', '[': 'LOOP_START', ']': 'LOOP_END'} @classmethod def decode(cls, source: str) -> list: return [cls.SYMBOLS[ch] for ch in source if ch in cls.SYMBOLS]主流程改成LANG_REGISTRY[ext].decode(source),加新语言只需新建一个文件、写一个decode类方法、挂上@register装饰器。验证方法是:给新语言写一个最小示例(比如只输出单个字符),跑通后再逐步加复杂度。这种改造的收益在第三种语言之后才明显,但边界清晰带来的调试便利是立刻能感受到的。
5.2 加一层 AST 做静态检查
直接执行操作码列表的问题是错误只能在运行时暴露。一个实用的增强是在解码后、执行前插一层轻量 AST 检查,把括号配对、指针越界风险、空循环这类问题提前拦下来:
| 检查项 | 触发条件 | 处理方式 |
|---|---|---|
| 括号配对 | [和]数量不等或嵌套错位 | 解码阶段直接报错,带行号 |
| 空循环 | []或[后紧跟] | 警告,提示可能是笔误 |
| 指针下界 | 连续PTR_DEC超过初始偏移 | 警告,运行时可能越界 |
| 输出前未赋值 | OUTPUT前该单元从未被VAL_INC触及 | 提示,输出可能是 0 字符 |
这张表不用全实现,挑括号配对和空循环两项就够覆盖大部分手写源码的低级错误。实现上就是在build_jump_table旁边加一个lint(ops)函数,返回警告列表,main.py里打印出来但不阻断执行——静态检查是辅助,不是门槛。
5.3 把执行器改成可单步调试
教学场景下,能单步看纸带变化比直接出结果有价值得多。改造思路是把execute拆成「取一条指令 → 执行 → 返回新状态」的生成器,外层用循环驱动,每步打印ip、ptr、tape[ptr]和最近几条输出:
def step_generator(ops: list, tape_size: int = 30000): tape, ptr, ip = [0] * tape_size, 0, 0 jump = build_jump_table(ops) while ip < len(ops): op = ops[ip] # ... 单条指令执行逻辑,和 execute 一致 ... yield {'ip': ip, 'ptr': ptr, 'val': tape[ptr], 'op': op} ip += 1外层拿到每一步的状态后,可以打印、可以存成 JSON 做可视化、也可以喂给前端做动画。我一般会先拿hello.bf跑一遍,确认步数和操作码数量对得上,再拿一个带嵌套循环的程序验证跳转步骤是否正确。从那以后我每次改执行器逻辑,都强制先用单步模式跑一遍最小示例,确认ip的跳转轨迹符合预期,再切回批量执行——这个习惯帮我挡掉了至少三次「循环跳转差一位」的回归 bug。希望帮到你。
本文还有配套的精品资源,点击获取