dis2py 开发思路 ------ 从零打造一个 Python 字节码反编译器
项目地址:github.com/qxqycb/exe2... 我有一个 PyInstaller 打包的 exe,问我能不能把源码弄出来。
市面上能用的 Python 反编译工具就那么几个:uncompyle6、pycdc、decompyle3。但问题是,uncompyle6 只支持到 Python 3.8,decompyle3 停在 3.7,而 pycdc 虽然号称支持到 3.14,但实际用起来你会发现它的输出经常有各种奇怪的 bug------比如函数体里莫名其妙出现 None(None, None) 这种垃圾代码,或者关键函数反编译不完整,只留一句 # WARNING: Decompyle incomplete。
最要命的是,这些工具都是 C/C++ 写的,出了问题你根本没法改。你只能等作者更新,或者换一个工具碰运气。
所以我就在想:能不能用 Python 自己写一个?就用标准库里的 dis 模块,把字节码一行一行翻译回源码。
这个想法其实不新鲜。很多人都想过,但大部分人做到一半就放弃了,因为字节码反编译比想象中难得多。不过我当时想的是,就算做不到 100% 还原,能还原 80% 也够用了------剩下的手动补一下就行。
反编译的第一步,是把字节码指令组织成方便处理的形式。Python 的 dis 模块提供了 dis.get_instructions() 函数,可以拿到一个代码对象的所有指令。每条指令有操作码(opname)、参数(argval)、偏移量(offset)等信息。
我写了一个 InstructionStream 类来封装这些指令列表,提供游标和前视功能。这个类的设计思路很简单:我需要一个能"向前看"的迭代器,因为很多模式匹配需要检查接下来几条指令是什么。
python
class InstructionStream:
def peek(self, n=0):
"""前瞻 n 条指令"""
idx = self.pos + n
if idx < len(self.instructions):
return self.instructions[idx]
return None
有了这个基础,我开始写表达式求值器。
表达式求值是整个反编译器的基础。一个简单的赋值语句 x = a + b,在字节码里是:
css
LOAD_FAST a
LOAD_FAST b
BINARY_OP + # Python 3.11
STORE_FAST x
看起来很简单对吧?但问题是,表达式可以嵌套得很深。比如 x = obj.method(a, b + c) 会生成一堆指令,你需要从后往前回溯,重建整个表达式树。
我写的 _backtrack_expr 函数就是做这个的。它从表达式末尾的指令开始,递归地向前回溯,遇到二元运算就分别求左右操作数,遇到函数调用就收集参数,遇到属性访问就拼接 . 符号。
这个函数是整个项目最核心、也最复杂的一个函数。它需要处理:
- 常量(
LOAD_CONST)------ 数字、字符串、None、True/False - 变量(
LOAD_FAST、LOAD_DEREF、LOAD_GLOBAL、LOAD_NAME) - 属性访问(
LOAD_ATTR)------obj.attr - 方法调用(
LOAD_METHOD)------obj.method(args) - 函数调用(
CALL、PRECALL)------ Python 3.11 新增了 PRECALL 指令 - 二元运算(
BINARY_OP)------ Python 3.11 把之前分散的 BINARY_ADD、BINARY_SUBTRACT 等合并成了一个 BINARY_OP - 比较运算(
COMPARE_OP) - 下标访问(
BINARY_SUBSCR)------obj[key] - 列表/元组/字典/集合构建(
BUILD_LIST、BUILD_TUPLE、BUILD_MAP、BUILD_SET) - f-string(
BUILD_STRING、FORMAT_VALUE) - 一元运算(
UNARY_NEGATIVE、UNARY_NOT等)
每个操作码的处理逻辑都不太一样。比如 LOAD_ATTR 需要和前一条指令拼接成 prev.attr 的形式,CALL 需要收集前面的参数,BUILD_STRING 需要把多个字符串片段拼接成 f-string。
有了表达式求值,接下来就是识别各种语句模式。
我写了一套模式匹配函数,每个函数识别一种特定的语句模式:
_match_simple_assign------ 识别target = value赋值_match_dict_literal------ 识别{key: value, ...}字典字面量_match_for_range------ 识别for var in range(...):_match_try_except------ 识别try:/except:块_match_if_block------ 识别if condition:/else:_match_return------ 识别return语句_match_binary_op_assign------ 识别var += val增强赋值_match_complex_call------ 识别带关键字参数的复杂调用_match_raise------ 识别raise语句_match_print------ 识别print()调用_match_with------ 识别with xxx:语句
这些模式匹配器是线性扫描的,每条指令依次检查是否匹配某个模式。如果匹配成功,就输出对应的 Python 代码;如果都不匹配,就跳过。
这个阶段的反编译器已经能处理一些简单的函数了,但还原率很低。因为很多复杂的控制流结构------比如嵌套的 if/for/try、布尔表达式、推导式------都还没处理。
线性扫描最大的问题是无法处理嵌套。比如一个 if 里面嵌套一个 for,for 里面又嵌套一个 if,这种结构线性扫描根本搞不定。
于是我把反编译器重构成了块级递归的形式。核心思想是:把字节码分成若干个"块",每个块对应一个代码区域(比如 if 的 then 分支、else 分支),然后递归地反编译每个块。
Python 3.11 引入了一个重要的变化:异常处理不再使用 SETUP_FINALLY、POP_BLOCK 等指令,而是使用异常表(exception table)。异常表是一个独立的数据结构,记录了每个 try 块的起止偏移和对应的 handler 偏移。
这个变化让反编译变得更容易,因为异常表的边界是精确的------你不需要通过扫描指令来猜测 try 块的结束位置,直接查表就行。
我写了一个 _parse_exception_table 函数来解析异常表。Python 3.11 的异常表使用 varint 编码,每个条目包含四个字段:start、end、target、depth。其中 depth=0 表示这是最外层的 try 块,depth>=1 表示嵌套的 except/finally 子句。
ini
try_start_info = {}
for s, e, t, d in exc_table:
if d == 0 and e - s > 2:
try_start_info[s] = (e, t)
有了异常表,try/except 的边界就非常清楚了。try 体的范围是 [start, end),handler 的起始位置是 target。
_decompile_block 函数是块级反编译的核心。它接受一个指令范围 [start_idx, end_idx],递归地反编译这个范围内的代码。
在遍历指令时,它会检查每条指令是否属于某个嵌套结构:
- 如果遇到
FOR_ITER,说明这是一个 for 循环,递归处理循环体 - 如果遇到
POP_JUMP_FORWARD_IF_FALSE,说明这是一个 if 条件,递归处理 if 体和 else 体 - 如果遇到
PUSH_EXC_INFO/CHECK_EXC_MATCH,说明进入了异常处理 - 如果遇到
BEFORE_WITH,说明这是一个 with 语句
对于每种嵌套结构,它会:
- 确定结构的边界(开始和结束位置)
- 递归调用
_decompile_block处理子块 - 跳过已处理的指令,继续处理后续代码
这种递归结构让处理任意深度的嵌套成为可能。
推导式(list comprehension、dict comprehension、set comprehension、generator expression)在字节码中是一个独立的代码对象。比如 [x for x in y] 会生成一个 <listcomp> 代码对象。
我写了一个 _match_comprehension 函数来识别推导式模式:LOAD_CONST <comp_code> + MAKE_FUNCTION + ... + GET_ITER + CALL。
然后又写了一个 _decompile_comprehension 函数来反编译推导式内部代码,提取循环变量、表达式和条件。
推导式的输出格式是 [expr for var in iterable if condition],不同的推导式类型用不同的括号:list 用 [],dict 和 set 用 {},generator 用 ()。
随着测试的深入,我发现表达式求值器有很多遗漏。以下是我逐步添加的功能:
Python 的 and 和 or 是短路运算符。a and b 在字节码中不是简单的二元运算,而是一个条件跳转:
css
LOAD_FAST a
JUMP_IF_FALSE_OR_POP target # 如果 a 为假,跳到 target
LOAD_FAST b
>> target:
这个模式在 _backtrack_expr 中很难处理,因为它是向后的线性回溯,而布尔运算涉及向前跳转。
我的解决方案是写了一个 _reconstruct_expr 包装函数。它在 _backtrack_expr 返回后,继续检查返回的索引是否指向一个布尔操作符(JUMP_IF_FALSE_OR_POP 或 JUMP_IF_TRUE_OR_POP)。如果是,就继续回溯左操作数,然后组合成 left and right 或 left or right。
但这还不够。对于 a and b or c 这种混合表达式,字节码中会出现 POP_JUMP_FORWARD_IF_FALSE 和 JUMP_IF_TRUE_OR_POP 的组合。POP_JUMP_FORWARD_IF_FALSE 是 and 的短路跳转,JUMP_IF_TRUE_OR_POP 是 or 的短路跳转。
处理这种混合表达式需要更复杂的逻辑。我采用的方法是:在布尔链中检测 POP_JUMP_FORWARD_IF_FALSE,找到它的右操作数(向前扫描到下一个布尔操作符),然后替换当前表达式中的右操作数部分。
这里有一个细节:and 的优先级高于 or,所以 a and b or c 应该被解析为 (a and b) or c。在回溯链中,or 会先被处理(因为它更靠近表达式末尾),然后 and 需要"拆开"已经组合好的表达式,把 b 替换为 (a and b)。
这个逻辑用字符串替换实现,虽然不够优雅,但能处理绝大多数实际场景。
三元表达式在字节码中的模式是:
ruby
cond
POP_JUMP_FORWARD_IF_FALSE else_target
true_val
JUMP_FORWARD end_target
>> else_target:
false_val
>> end_target:
这个模式和 if/else 语句非常相似,区别在于三元表达式的"体"只有一个表达式,而 if 语句的体是一个代码块。
我的检测方法是:在 _reconstruct_expr 中,当遇到 JUMP_FORWARD 时,检查它的跳转目标是否恰好是当前语句的位置。如果是,就说明这是一个三元表达式:true_val 在 JUMP_FORWARD 之前,false_val 是当前已经求值出的表达式,cond 在 POP_JUMP_FORWARD_IF_FALSE 之前。
同时,在 if 语句处理器中,我也加了检测:如果 body 中只有一个表达式且末尾是 JUMP_FORWARD 跳到 body 结束之后,就跳过 if 处理,交给 _reconstruct_expr 处理。
切片 lst[1:3] 的字节码是:
LOAD_FAST lst
LOAD_CONST 1
LOAD_CONST 3
BUILD_SLICE 2
BINARY_SUBSCR
BUILD_SLICE 的参数表示切片有几个部分(2 表示 start:stop,3 表示 start:stop:step)。在 _backtrack_expr 中,我添加了 BUILD_SLICE 的处理,把各个部分组合成 start:stop 或 start:stop:step 的形式。
None 值表示省略的部分,比如 lst[1:] 的 stop 是 None,我把它转换为空字符串。
lambda 在字节码中是一个独立的代码对象,通过 MAKE_FUNCTION 创建:
css
LOAD_CONST <code object <lambda>>
MAKE_FUNCTION 0
我在 _backtrack_expr 中添加了 MAKE_FUNCTION 的处理。当遇到 MAKE_FUNCTION 时,检查前面的 LOAD_CONST 是否是一个 lambda 代码对象。如果是,就反编译 lambda 体,提取参数列表,输出 lambda params: body。
lambda 默认参数的处理稍微复杂一点。默认值通过一个元组传递,在 LOAD_CONST 代码对象之前:
python
LOAD_CONST (10,) # 默认值元组
LOAD_CONST <lambda code>
MAKE_FUNCTION 1 # flags=1 表示有默认值
我通过 MAKE_FUNCTION 的 flags 参数判断是否有默认值(flags & 0x01),如果有就向前找默认值元组,然后传给 _build_params 函数。
obj.method1().method2().method3() 的字节码是一串 LOAD_METHOD + PRECALL + CALL 的组合:
objectivec
LOAD_FAST obj
LOAD_METHOD method1
PRECALL 0
CALL 0
LOAD_METHOD method2
PRECALL 0
CALL 0
LOAD_METHOD method3
PRECALL 0
CALL 0
在 _backtrack_expr 中,LOAD_METHOD 的处理会收集参数并生成 .method(args) 的字符串。由于 _backtrack_expr 是递归的,每次遇到 LOAD_METHOD 都会在前一个表达式的基础上追加 .method(args),自然就形成了链式调用。
while 循环在字节码中看起来和 if 语句很像------都是 POP_JUMP_FORWARD_IF_FALSE 开头。区别在于,while 循环的 body 末尾有一个回跳指令(JUMP_BACKWARD 或 POP_JUMP_BACKWARD_IF_TRUE)。
ruby
>> loop_start:
condition
POP_JUMP_FORWARD_IF_FALSE exit_target
body
JUMP_BACKWARD loop_start
>> exit_target:
我的检测方法是在 if 语句处理器中,检查 body 的最后一条非 skip 指令是否是回跳指令。如果是,且回跳目标在 body 内部,就说明这是一个 while 循环。
对于 while True: 的情况,字节码中没有 POP_JUMP_FORWARD_IF_FALSE(因为条件永远为真),而是用 NOP 占位,body 末尾用 JUMP_BACKWARD 跳回 body 开头。我通过检测 NOP 指令是否被 JUMP_BACKWARD 作为目标来识别这种模式。
Python 的 if a and b: 在字节码中不是一条指令,而是两条 POP_JUMP_FORWARD_IF_FALSE,都跳转到同一个 else 目标:
less
LOAD_FAST a
POP_JUMP_FORWARD_IF_FALSE else_target
LOAD_FAST b
POP_JUMP_FORWARD_IF_FALSE else_target
# if body
>> else_target:
# else body
我的检测方法是在 body 中扫描是否有另一个 POP_JUMP_FORWARD_IF_FALSE 跳转到相同的目标。如果有,就把两个条件合并成 a and b。
if a or b: 的检测类似,只是第一个跳转是 POP_JUMP_FORWARD_IF_TRUE(如果 a 为真就直接进入 body),第二个是 POP_JUMP_FORWARD_IF_FALSE(如果 b 为假就跳到 else)。
with 语句的字节码结构比较复杂,因为它包含了 __enter__ 和 __exit__ 的隐式调用:
python
BEFORE_WITH
STORE_FAST f # as f
# with body
LOAD_CONST None # __exit__ 参数
LOAD_CONST None
LOAD_CONST None
PRECALL 2
CALL 2 # __exit__()
POP_TOP
JUMP_FORWARD N # 跳过异常处理器
# 异常处理器 (PUSH_EXC_INFO, WITH_EXCEPT_START, ...)
>> target:
# with 之后的代码
我的 with 语句处理器会:
- 识别
BEFORE_WITH指令 - 回溯 with 表达式(如
open('test.txt')) - 检测
STORE_FAST获取as变量名 - 扫描 body 直到找到
__exit__调用(三个连续的LOAD_CONST None) - 跳过
__exit__调用和异常处理器
早期版本的一个 bug 是清理循环太激进,把 with 之后的正常代码(如 return 语句)也跳过了。修复方法是精确计算 JUMP_FORWARD 的跳转目标,只跳到目标位置,不继续跳过后续的正常代码。
try/except 的处理主要依赖异常表。Python 3.11 的异常表提供了精确的 try 块边界,所以 try 体的范围是确定的。
except 子句的处理比较复杂。在字节码中,except 的入口是 PUSH_EXC_INFO + CHECK_EXC_MATCH 的组合。CHECK_EXC_MATCH 检查异常类型是否匹配,如果匹配就进入 except 体,否则跳转到下一个 except 或 re-raise。
我花了很长时间处理 except 体的边界。一个关键问题是 POP_EXCEPT 指令的位置------它标志着异常处理入口的结束,但 except 体的实际代码在 POP_EXCEPT 之后。我最初的代码把 POP_EXCEPT 当成了 except 体的开始,导致 except 体总是空的。修复方法是在找到 except 入口后,跳过 POP_EXCEPT,再从下一个指令开始作为 except 体。
else 子句的检测相对简单:else 子句是 try 体和 handler 之间的代码。如果 try_end 和 handler_start 之间有非 skip 指令,就说明有 else 子句。
finally 子句的检测需要区分 handler 的类型。如果 handler 中有 CHECK_EXC_MATCH,说明是 except 处理器;如果没有,说明是 finally 处理器。finally 的 body 在两个地方出现:正常路径(在 try 体之后)和异常路径(在 handler 中)。
装饰器是 Python 中非常常见的模式。在字节码中,装饰器的形式是:
swift
LOAD_NAME decorator
LOAD_CONST <code object func>
MAKE_FUNCTION 0
PRECALL 0
CALL 0
STORE_NAME func
链式装饰器 @dec1 @dec2 的字节码会有多个 CALL:
swift
LOAD_NAME dec1
LOAD_NAME dec2
LOAD_CONST <code object func>
MAKE_FUNCTION 0
PRECALL 0
CALL 0 # dec2(func)
PRECALL 0
CALL 0 # dec1(dec2(func))
STORE_NAME func
带参数的装饰器 @dec(args) 的字节码会更复杂,装饰器表达式本身包含一个 CALL。
我的 _try_parse_decorator 函数的检测逻辑是:
- 从
STORE_NAME往前扫描,找到CALL指令 - 收集所有连续的
CALL指令(每个对应一个装饰器调用) - 找到
MAKE_FUNCTION和前面的LOAD_CONST(代码对象) - 从
LOAD_CONST之前回溯每个装饰器的表达式 - 装饰器的数量等于 CALL 的数量
对于类方法装饰器(如 @staticmethod、@classmethod、@property),检测逻辑相同,但需要在类体字节码中执行。我在 decompile_module 中为每个类体单独解析字节码,建立方法名到装饰器列表的映射。