众所周知,执行python程序可以直接使用命令,如下所示:
python abc.py
看到python直接执行了abc.py,可能很多同学认为python是解释执行abc.py的,其实不然。如果要真是解释执行,那效率慢的就没法用了。实际上,Python与Java一样,也是玩字节码出身。Java的字节码叫Java ByteCode,Python的字节码叫Python ByteCode。Python在第一次运行abc.py文件时,会将源代码文件编译成字节码,然后再执行。当然,还可以选择直接生成字节码文件(扩展名是pyc),然后直接执行Python字节码文件。
通常Python是以源代码形式发布的,不过对于一些敏感信息,不希望以源代码形式发布,就可以用字节码形式发布。当然,字节码也可以被反编译。为了让Python源代码更安全,可以制作自己的私有Python环境,这些内容我们后面再说。
相信很多没接触过过Python字节码的同学一定有很多疑问,那么就继续看后面的内容吧!
1. 如何查看Python字节码
我们首先来查看一下Python的字节码,以证明在运行Python脚本时确实是先将Python代码编译成字节码,然后执行的是字节码,而不是直接执行Python源代码。
先看下面的代码:
在这段代码中有一个fun函数,里面使用了全局变量value和局部变量name,并输出了这两个变量的值。最后导入了dis模块。在该模块中有一个disassemble函数,用于输出任何包含__code__属性的Python代码段的字节码形式。
现在执行这段代码,会输出如下内容:
很明显,disassemble输出了类似汇编代码的东西。其实这就是Python字节码的可读形式。每一条指令对应一个字节码。那么为什么要查看字节码呢?其实对于应用开发者来说,最直接的作用就是更好地理解Python源代码。
例如,本例使用了全局变量,也就是global关键字,那么global关键字到底代表什么呢?从Python字节码中就可以很容易看出端倪。
在Python源代码中发生了2次赋值,代码如下:
其中value是全局变量,name是fun函数的局部变量。将这两条赋值操作转换为Python字节码,会得到如下的代码:
从Python字节码可以看出,每一条赋值语句转换成了2条Python字节码。其中都使用了LOAD_CONST指令,这是装载常量的指令。因为value和name都被赋予了一个常量,只是一个是整数,另一个是字符串。不过由于Python在使用变量时不需要指定变量类型(变量有类型,但不需要在定义变量时指定,使用变量时再确定变量的类型),所以不管是装载什么类型的常量给变量赋值,都使用LOAD_CONST指令。
但第2条指令就不同了,对于全局变量value,使用STORE_GLOBAL指令将常量赋给变量,而局部变量name,使用了STORE_FAST指令将常量赋给了变量。这两条指令的区别就是存储的位置不同。由于Python将全局变量和变量放到了不同的位置,所以这两条指令会分别将常量值保存到这些位置。
从这一点判断,global value这条语句其实并没有执行,他只是一个开关,如果加上global value,当为value赋值时就使用STORE_GLOBAL指令,如果没有global value,当为value赋值时就使用STORE_FAST指令。
如果除了global value外,其他的代码都去掉,就看不到global value的身影了。
看下面的Python代码:
执行这段代码,只会得到下面2条Python字节码:
这2条Python字节码实际是让fun函数有一个默认的返回值,也就是如果函数不显式返回一个值,那么默认就会返回None。这里面并没有看到global value的身影。
2. 用Python代码编译Python代码
在使用python命令运行脚本时,尽管将Python源代码编译成了字节码,但并没有将编译结果保存成文件,而一切都是在内存中完成的。如果频繁运行Python的某段程序,运行的实际上是内存中的Python字节码。不过在发布时,我们期望像Java一样,可以发布.class文件,其实Python也有类似的文件,这就是.pyc文件。
用Python代码和命令行都可以将Python源代码编译成.pyc文件,只是在默认情况下,Python做得比较隐蔽,会将.pyc文件生成到一个默认的目录,而且很多IDE(如PyCharm)是不会显示这个目录的。这个目录就是__pycache__。
现在做一个实验,首先创建一个demo.py文件,然后输入下面的代码:
现在执行下面的代码将demo.py文件编译生成.pyc文件。
so easy,只需要两行代码(还有一行是import语句),就可以编译demo.py,运行程序后,如果在IDE中,什么都不会发生,别急,切换到demo.py文件所在的目录,会看到多了一个__pycache__目录,打开一看,目录里有一个名为demo.c的文件。在读者的机器上文件名可能不同,差异就在最后的数字上,这里的38表示我用的Python版本是3.8,这里不会显示小版本号。如果读者使用的是3.7,那么生成的.pyc文件就是demo.c。
现在进入控制台,进入demo.c文件所在的目录,执行python demo.c命令,同样可以输出结果,与python demo.py执行的结果完全相同。所以在发布Python应用时,可以直接发布pyc文件。
compile函数在编译Python文件时,可以指定第2个参数值,表示要生成的.pyc文件名,这样就可以指定将pyc文件放到特定的目录,代码如下:
执行这段代码,可以在当前目录生成一个名为demo.pyc的文件,执行python demo.pyc命令,同样会得到我们期望的结果。
如果需要编译的Python脚本太多,可以多次调用compile函数,也可以使用compileall模块中的compile_dir函数递归编译指定目录中的所有Python脚本文件。
现在做一个实验,在当前目录创建3层子目录:aa/bb/cc,并在每一层目录创建一个或多个Python脚本文件,可以不写任何代码(空文件即可),如图1所示。
图1
现在执行下面的代码编译aa目录中所有的Python脚本文件。
执行这段代码,首先会递归扫描所有的目录,然后会编译所有发现的Python脚本文件,如图2所示。
图2
查看这几个目录,每一个目录都有一个名为__pycache__目录,里面是对应的pyc文件。
如果不想递归编译所有目录中的Python脚本文件,可以使用compile_dir函数的第2个参数指定递归层次,0表示当前目录(不递归),1表示递归一层目录,以此类推。例如,下面的代码只编译当前目录中所有的Python脚本文件。
3. 在命令行中编译Python脚本
python命令同样可以将.py文件编译成.pyc文件,例如,如果要编译demo.py文件,可以使用下面的命令:
python -m demo.py
这里的-m命令行参数表示编译demo.py,执行这行命令后,会在当前目录的__pycache__目录生成demo.c文件,然后可以使用python直接执行这个文件。
如果想递归编译目录中所有的Python文件,可以使用下面的命令:
python -m compileall aa
这行命令可以递归编译aa目录中的所有Python文件。如果还想对编译结果进行优化,可以加-O或-OO,那么这两个优化参数有什么区别呢?
如果不加优化参数,只加-m,那么就不会进行优化,也就是优化层次(Level)为0,当不优化时,Python的内部变量__debug__为True,读者可以在Python Shell中输出这个变量值。如果设置了-O参数,那么优化层次是1,在这一优化层次,会将__debug__变量的值设为False。如果使用-OO参数,优化层次是2,不仅将__debug__变量的值设为False,而且将Python中的docstrings也去除了。docstrings就是Python中的文档注释,可以用来为API自动产生文档。也就是3对单引号或双引号括起来的部分。
其中上一部分讲的compile函数和compile_dir函数也有设置优化level的参数,就拿compile函数来说,该函数的第4个参数用于设置优化层次,默认值是-1,相当于-O参数。还可以设置为0(不优化)、1(与默认值相同)和2(相当于-OO参数)。下面的代码用level = 2的层次优化编译demo.py。
('demo.py', 'demo.pyc', False, 2)
其实这里的优化,并不是指优化Python Byte Code,而是去掉不同的调试信息和文档。这里的调试信息主要是指为了在Console或日志中输出的一些用于展示程序执行状态的信息。如果这些随着程序发布,会让程序运行效率大打折扣。因为执行在Console或日志中输出信息的代码是很慢的(相对于直接在内存中执行的代码)。
如果使用命令行方式优化编译.py文件,如果使用的是-O参数,生成的目标文件是:demo.c,如果使用的是-OO参数,生成的目标文件是:demo.c。
4. 如何对Python代码加密
尽管可以将.py文件编译生成.pyc文件,但.pyc文件和Java的.class文件一样,很容易被反编译。更稳妥的方式是制作一个私有的Python编译和运行环境,说白了,就是修改Python编译器的源代码。听着很高大上,其实并不复杂,只需要修改其中的常量即可。
首先下载Python源代码,然后找到如下两个文件:
<Python源代码根目录>/Lib <Python源代码根目录>/Include
大家可以打开这两个文件看看,o文件中的代码片段是这样的:
o文件中的代码片段是这样的:
我们可以看到,在o文件中定义了一堆宏(相当于常量),而o文件中同样定义了与o同名的值,对应的整数值也相等。做过编译器的同学应该能猜出来这是什么东西,其实就是Python Byte Code对应的指令编码。编译出来的.pyc文件都是由这些指令组成的。例如,for指令定义如下:
也就是说,如果Python代码中有for循环,就一定会有这个指令。我们可以做个试验,下面有一段包含1个for循环的Python代码:
输出这段这段代码的Python字节码,如下:
我们可以看到,第4行就是FOR_ITER指令,每一条指令由2个字节组成,第1个字节表示指令本身,第2个字节表示操作数。而在第11行的JUMP_ABSOLUTE指令是跳转指令,FOR_ITER与JUMP_ABSOLUTE配合才能形成循环。JUMP_ABSOLUTE直接跳到了6,也就是FOR_ITER指令所在的位置。
由于FOR_ITER指令对应的数值是93,这是十进制,转换为十六进制是5d,如果考虑后面的操作数12(十六进程是0C,至于为什么操作数是12,这是FOR_ITER指令的特性,读者可以查阅Python字节码的相关文档,这个问题与本文无关,这里先不做阐述),那么完整的指令应该是5d0c。所以编译demo.py,生成对应的.pyc文件,然后打开.pyc文件(用可以查看二进制数据的软件打开),会看到如图1所示的十六进制形式的代码,在第6行可以找到5d0c,这就是for循环的起始指令。
图3
读者可以再加一个for循环,代码如下:
查看pyc文件的代码,会看到如图4的形式。很明显,第6行和第7行都有5d0c指令,这就表明这段代码中包含2条for语句。
图4
Python字节码的反编译器都是根据这些规则实现的,但问题是,如果5d不表示for循环,而表示if语句,那么原有的反编译器岂不是不好使了。
如果在代码中有if语句,那么根据不同的场景,会使用POP_JUMP_IF_FALSE指令或POP_JUMP_IF_TRUE指令,这两条指令在o的定义如下:
如果有下面的Python代码:
那么会使用POP_JUMP_IF_FALSE指令,这时pyc代码中就会包含72(114的十六进制表示),但如果将FOR_ITER的93和POP_JUMP_IF_FALSE的114调换一下,变成如下形式,那么按Python的标准指令会将for当成if,if当成for,这样反编译出来的代码就乱套了。而反编译器是无法知道你是如何互换指令值得。这就像直接用标准的base64编码是无法加密的,但如果将标准的base64编码随机打乱,用这个打乱的base64编码规则进行编码,是无法用标准的base64编码表解码的。除非拿到了变化后的base64编码表,如果要测试每一种排列,会有64的阶乘这么多种可能,在有限的时间内是根本不可能破解的。而这种修改Python源代码的方式,就相当于打乱标准base64编码表的顺序,增加了破解的难度和时间。
另外,光修改前面介绍的两个文件还不行,还需要修改另外一个文件,路径如下:
<Python源代码根目录>/Python
读者可以打开这个文件,看看为什么要修改这个文件,文件的代码片段如下:
很明显,这段代码用来定义Python字节码的指令,而在o文件中定义的每一个宏对应的值,就是opcode_targets数组的索引。我们知道,C语言数组索引从0开始,所以opcode_targets数组的第1个元素是一个占位符(&&_unknown_opcode),而POP_TOP指令在o文件中值正是1,所以正好与opcode_targets数组的第2个元素对应。
我们可以继续查看opcode_targets数组的代码,看到下面的代码形式:找到TARGET_INPLACE_TRUE_DIVIDE,对应的是INPLACE_TRUE_DIVIDE指令,如图5所示。
图5
然后在o文件中找到INPLACE_TRUE_DIVIDE指令,正好值是29,正好对应opcode_targets中索引为29的元素值。而TARGET_INPLACE_TRUE_DIVIDE下面是一堆&&_unknown_opcode占位符,这也说明INPLACE_TRUE_DIVIDE后面有很多空闲的值,再看看o文件中的定义,如图6所示。
图6
很显然,INPLACE_TRUE_DIVIDE指令后面的RERAISE指令就直接从48开始了,所以要用多个&&_unknown_opcode作为占位符,否则就无法找到对应的指令了。
所以修改Python源代码要遵循下面的规则:
(1)修改o文件和o文件中代码,要统一互换,不能只互换一个;
(2)然后将o中opcode_targets数组的相对位置也换过来,否则就无法找到对应的指令了;
都改完了,然后就可以编译Python代码了,执行下面的命令即可:
configure
make
make install
最后在发布程序时,需要带上自己编译的Python环境,标准的Python环境已经无法运行我们自己生成的pyc文件了。
当然,包含Python代码的方式有好很多种,例如,对Python代码混淆、将Python代码转换成C代码等等,这些内容我后面会专门写文章讲解。