python 解析字节码的相关方法

python 解析字节码的相关方法

python代码被解释器执行时分为两步走:
一、python编译器将代码编译成字节码
二、python虚拟机执行字节码
由于这两步是一起的,所以在python编程中很少能看到字节码。但是想要练就火眼金睛就必须知道一段python源码对应的字节码是怎么样的,拨开迷雾看本职。本文分析python常见的能编译成字节码相关的函数。

compile

compile() 函数是python的内置函数,功能是将python源代码编译为code对象或AST对象。

函数定义:

compile(source, filename, mode[, flags[, dont_inherit]])

参数说明:

  • source:字符串或AST(Abstract Syntax Trees)对象,表示需要进行编译的Python代码
  • filename:指定需要编译的代码文件名称,如果不是从文件读取代码则传递一些可辨认的值(通常是用'')
  • mode:用于标识必须当做那类代码来编译,规则如下:
    如果source是由一个代码语句序列组成,则指定mode='exec';
    如果source是由单个表达式组成,则指定mode='eval';
    如果source是由一个单独的交互式语句组成,则指定mode='single'。
  • 另外两个可选参数暂不做介绍

简单小例子

ss = """
a = 100
b = 200
c = a + b
print(c)
"""

co = compile(ss, 'string', 'exec')
print(co)
print(dir(co))
exec(ss)
<code object <module> at 0x7f51c25e8810, file "string", line 2>
['__class__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'co_argcount', 'co_cellvars', 'co_code', 'co_consts', 'co_filename', 'co_firstlineno', 'co_flags', 'co_freevars', 'co_kwonlyargcount', 'co_lnotab', 'co_name', 'co_names', 'co_nlocals', 'co_stacksize', 'co_varnames']
300

变量co就是一个字节码对象,通过dir(co)能够看到该字节码对象中有很多属性。code对象是一个对象,在Cpyhon解释器中,该对象就是一个结构体,结构体如下:

typedef struct {
    PyObject_HEAD		/* 头部信息, 我们看到真的一切皆对象, 字节码也是个对象 */	
    int co_argcount;            /* 可以通过位置参数传递的参数个数 */
    int co_posonlyargcount;     /* 只能通过位置参数传递的参数个数,  Python3.8新增 */
    int co_kwonlyargcount;      /* 只能通过关键字参数传递的参数个数 */
    int co_nlocals;             /* 代码块中局部变量的个数,也包括参数 */
    int co_stacksize;           /* 执行该段代码块需要的栈空间 */
    int co_flags;               /* 参数类型标识 */
    int co_firstlineno;         /* 代码块在对应文件的行号 */
    PyObject *co_code;          /* 指令集, 也就是字节码, 它是一个bytes对象 */
    PyObject *co_consts;        /* 常量池, 一个元组,保存代码块中的所有常量。 */
    PyObject *co_names;         /* 一个元组,保存代码块中引用的其它作用域的变量 */
    PyObject *co_varnames;      /* 一个元组,保存当前作用域中的变量 */
    PyObject *co_freevars;      /* 内层函数引用的外层函数的作用域中的变量 */
    PyObject *co_cellvars;      /* 外层函数中作用域中被内层函数引用的变量,本质上和co_freevars是一样的 */
 
    Py_ssize_t *co_cell2arg;    /* 无需关注 */
    PyObject *co_filename;      /* 代码块所在的文件名 */
    PyObject *co_name;          /* 代码块的名字,通常是函数名或者类名 */
    PyObject *co_lnotab;        /* 字节码指令与python源代码的行号之间的对应关系,以PyByteObject的形式存在 */
    
    //剩下的无需关注了
    void *co_zombieframe;       /* for optimization only (see frameobject.c) */
    PyObject *co_weakreflist;   /* to support weakrefs to code objects */
    void *co_extra;
    unsigned char *co_opcache_map;
    _PyOpcache *co_opcache;
    int co_opcache_flag; 
    unsigned char co_opcache_size; 
} PyCodeObject;

可以看到该结构提中的有很多成员变量,这些成员变量也反应在co的变量和方法中。比较总要的有:

  1. co_code 字节码
  2. co_consts 常量池
  3. co_names 符号池

到这里我们能看到的还是code对象,并没有看到具体的字节码,co_code 是指向具体的字节码,但是是2进制,不便于查看。所有如果想看到纯粹的字节码,我们可以用下面的dis模块。

dis

dis是python的标准库模块,功能是将一段python代码编译成字节码指令。

python代码的执行过程分为两步:1.python解释器将代码编译成字节码;2.python虚拟机执行字节码。通常这两个步骤是一次性完成,所以我们看不到中间状态的字节码。而dis就可以将一段源码编译成字节码。

从上面的code对象中可以到看字节码是其一个成员变量co_code,字节码是被底层结构体PyCodeObject的成员co_code指向,那么dis可以取出字节码指令。

小例子

import dis

ss = """
a = 100
b = 200
c = a + b
print(c)
"""


byte_str = dis.dis(ss)

ss这一段python代码的字节码就如下:

  2           0 LOAD_CONST               0 (100)
              2 STORE_NAME               0 (a)

  3           4 LOAD_CONST               1 (200)
              6 STORE_NAME               1 (b)

  4           8 LOAD_NAME                0 (a)
             10 LOAD_NAME                1 (b)
             12 BINARY_ADD
             14 STORE_NAME               2 (c)

  5          16 LOAD_NAME                3 (print)
             18 LOAD_NAME                2 (c)
             20 CALL_FUNCTION            1
             22 POP_TOP
             24 LOAD_CONST               2 (None)
             26 RETURN_VALUE

首先解释一下,python编译器编译过一段代码之后,生成的是code对象,对象就是compile中看到的结构体。这个对象中有字节码信息co_code,也有一些变量的信息,就是 co_consts 常量池 和 co_names 符号池。那么把这两个成员也打印出来。

(100, 200, None)
('a', 'b', 'c', 'print')

下面解释一下字节码。这段字节码就是python源码被编译之后的样子,也是最终被执行的对象。

2             0 LOAD_CONST               0 (100)
              2 STORE_NAME               0 (a)

2 代表字节码对应的源码的行号
0 LOAD_CONST 代表字节码的行号和字节码指令
0 (100) 代表字节码的操作数

完整的说

2             0 LOAD_CONST               0 (100)

意思是:从常量池加载一个常量,加载的常量是序号为0,值为100

2 STORE_NAME               0 (a)

意思是:从符号池取一个符号,符号的序号是0,值为a,绑定到上一个操作的常量100。然后将a=100,存入命名空间中。

3           4 LOAD_CONST               1 (200)
            6 STORE_NAME               1 (b)

和上面的类似。从常量池中取下标为1的常量,值为200,然后去符号池去下标为1的符号为b,绑定b=200,然后存入到命名空间中。

              8 LOAD_NAME                0 (a)
             10 LOAD_NAME                1 (b)
             12 BINARY_ADD
             14 STORE_NAME               2 (c)
8 LOAD_NAME                0 (a)

加载一个符号对应的值,符号是a,所以加载的值是100,

10 LOAD_NAME                1 (b)

加载符号b对应的值,值为200

12 BINARY_ADD

加法运算

14 STORE_NAME               2 (c)

从符号池去下标为2的元素,为符号c,然后绑定到上一步的结果300,然后存入到命名空间中。

inspect

inspect是python标准库模块,inspect模块四大功能:
1、类型检查(type checking)
2、获取源码(getting source code)
3、获取类和方法的参数信息(inspecting classes and functions)
4、解析堆栈(examining the interpreter stack)

其中对于分析代码字节码重要的包括:

inspect.stack() 获取调用者当前的堆栈信息
inspect.currentframe() 获取调用者当前Frame对象信息

小例子

import inspect


def fun_demo():
    a = 100
    b = 200
    c = a + b

    s_ob = inspect.stack()
    print(s_ob)

    f_ob = inspect.currentframe()
    print(f_ob)
    print(dir(f_ob))

   
fun_demo()
[FrameInfo(frame=<frame at 0x7f9e884e4048, file 'inspect_demo.py', line 10, code fun_demo>, filename='inspect_demo.py', lineno=9, function='fun_demo', code_context=['    s_ob = inspect.stack()\n'], index=0), FrameInfo(frame=<frame at 0x7f9e8861a9f8, file 'inspect_demo.py', line 18, code <module>>, filename='inspect_demo.py', lineno=18, function='<module>', code_context=['fun_demo()\n'], index=0)]
<frame at 0x7f9e884e4048, file 'inspect_demo.py', line 13, code fun_demo>
['__class__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'clear', 'f_back', 'f_builtins', 'f_code', 'f_globals', 'f_lasti', 'f_lineno', 'f_locals', 'f_trace', 'f_trace_lines', 'f_trace_opcodes']

Frame对象是python真正执行的对象,字节码对象code对象属于Frame对象
我们都知道python解释器是模拟了真实的机器,所以需要有堆栈信息,而Frame就是python代码执行的堆栈信息的体现。其在Cpython中是一个结构体,如下:

typedef struct _frame {
    PyObject_VAR_HEAD  		/* 可变对象的头部信息 */
    struct _frame *f_back;      /* 上一级栈帧, 也就是调用者的栈帧 */
    PyCodeObject *f_code;       /* PyCodeObject对象, 通过栈帧对象的f_code可以获取对应的PyCodeObject对象 */
    PyObject *f_builtins;       /* builtin命名空间,一个PyDictObject对象 */
    PyObject *f_globals;        /* global命名空间,一个PyDictObject对象 */
    PyObject *f_locals;         /* local命名空间,一个PyDictObject对象  */
    PyObject **f_valuestack;    /* 运行时的栈底位置 */

    PyObject **f_stacktop;      /* 运行时的栈顶位置 */
    PyObject *f_trace;          /* 回溯函数,打印异常栈 */
    char f_trace_lines;         /* 是否触发每一行的回溯事件 */
    char f_trace_opcodes;       /* 是否触发每一个操作码的回溯事件 */

    PyObject *f_gen;            /* 是否是生成器 */

    int f_lasti;                /* 上一条指令在f_code中的偏移量 */

    int f_lineno;               /* 当前字节码对应的源代码行 */
    int f_iblock;               /* 当前指令在栈f_blockstack中的索引 */
    char f_executing;           /* 当前栈帧是否仍在执行 */
    PyTryBlock f_blockstack[CO_MAXBLOCKS]; /* 用于try和loop代码块 */
    PyObject *f_localsplus[1];  /* 动态内存,维护局部变量+cell对象集合+free对象集合+运行时栈所需要的空间 */
} PyFrameObject;

其中 *f_code 就指向的是code对象,除此之外还有

  1. *f_builtins 代码执行的内建命名空间
  2. *f_globals 代码执行的全局命名空间
  3. *f_locals 代码执行的局部命名空间

小结

通过这三个模块来剖析额字节码,更多细节待补充。

上一篇:算法导论 思考题6-2


下一篇:python 程序的执行过程