2026/10/24 19:48:05

从Brainfuck到OoK:Python实现esolang解码与执行引擎

从Brainfuck到OoK:Python实现esolang解码与执行引擎 简介面向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(rOok[.?!], source) if len(tokens) % 2 ! 0: raise ValueError(fOoK token 数量为奇数({len(tokens)})源码可能不完整) ops [] for i in range(0, len(tokens), 2): pair f{tokens[i]} {tokens[i1]} 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(descriptionBrainfuck / OoK 解码执行器) parser.add_argument(file, help源码文件路径) parser.add_argument(--tape-size, typeint, default30000, help纸带单元数量默认 30000) args parser.parse_args() with open(args.file, r, encodingutf-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_sizeargs.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 指针越界但没报错现象程序输出了部分正确结果后突然变成乱码。原因是指针减到了 -1Python 的负索引让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。希望帮到你。本文还有配套的精品资源点击获取