《深度剖析CPython解释器》11.深入Python虚拟机,探索虚拟机执行字节码的奥秘古明地盆

这一次我们就来剖析Python运行字节码的原理,我们知道Python虚拟机是Python的核心,在源代码被编译成PyCodeObject对象时,就将由Python虚拟机接手整个工作。Python虚拟机会从PyCodeObject中读取字节码,并在当前的上下文中执行,直到所有的字节码都被执行完毕。

x86体系处理器通过栈维护调用关系,每次函数调用时就在栈上分配一个帧用于保存调用上下文以及临时存储。CPU中有两个关键寄存器,rsp指向当前栈顶,rbp指向当前栈帧。每次调用函数时,调用者(Caller)负责准备参数、保存返回地址,并跳转到被调用函数中执行代码;作为被调用者(Callee),函数先将当前rbp寄存器压入栈(保存调用者栈帧位置),并将rbp设为当前栈顶(保存当前新栈帧的位置)。由此,rbp寄存器与每个栈帧中保存调用者栈帧地址一起完美地维护了函数调用关系链。

我们以Python中的代码为例:

deff(a,b):returna+bdefg():returnf()g()当程序进入到函数f中执行时,那么显然调用者的帧就是函数g的栈帧,而当前帧则是f的栈帧。

解释一下:栈是先入后出的数据结构,从栈顶到栈底地址是增大的。对于一个函数而言,其所有对局部变量的操作都在自己的栈帧中完成,而调用函数的时候则会为调用的函数创建新的栈帧。

在上图中,我们看到运行时栈的地址是从高地址向低地址延伸的。当在函数g中调用函数f的时候,系统就会在地址空间中,于g的栈帧之后创建f的栈帧。当然在函数调用的时候,系统会保存上一个栈帧的栈指针(rsp)和帧指针(rbp)。当函数的调用完成时,系统就又会把rsp和rbp的值恢复为创建f栈帧之前的值,这样程序的流程就又回到了g函数中,当然程序的运行空间则也又回到了函数g的栈帧中,这就是可执行文件在x86机器上的运行原理。

而上一章我们说Python源代码经过编译之后,所有字节码指令以及其他静态信息都存储在PyCodeObject当中,那么是不是意味着Python虚拟机就在PyCodeObject对象上进行所有的动作呢?其实不能给出唯一的答案,因为尽管PyCodeObject包含了关键的字节码指令以及静态信息,但是有一个东西,是没有包含、也不可能包含的,就是程序运行的动态信息--执行环境。

var="satori"deff():var=666print(var)f()print(var)首先代码当中出现了两个print(var),它们的字节码指令是相同的,但是执行的效果却显然是不同的,这样的结果正是执行环境的不同所产生的。因为环境的不同,var的值也是不同的。因此同一个符号在不同环境中对应不同的类型、不同的值,必须在运行时进行动态地捕捉和维护,这些信息是不可能在PyCodeObject对象中被静态的存储的。

所以我们还需要执行环境,这里的执行环境和我们下面将要说的名字空间比较类似(名字空间暂时就简单地理解为作用域即可)。但是名字空间仅仅是执行环境的一部分,除了名字空间,在执行环境中,还包含了其他的一些信息。

因此对于上面代码,我们可以大致描述一下流程:

所以Python在运行时的时候,并不是在PyCodeObject对象上执行操作的,而是我们一直在说的栈帧对象(PyFrameObject),从名字也能看出来,这个栈帧也是一个对象。

对于Python而言,PyFrameObject可不仅仅只是类似于x86机器上看到的那个简简单单的栈帧,Python中的PyFrameObject实际上包含了更多的信息。

里面f_code成员是一个指针,指向相应的PyCodeObject对象,而接下来的f_builtins、f_globals、f_locals是三个独立的名字空间,在这里我们看到了名字空间和执行环境(即栈帧)之间的关系。名字空间实际上是维护这变量名和变量值的PyDictObject对象,所以在这三个PyDictObject对象中分别维护了各自name和value的对应关系。

在PyFrameObject的开头,有一个PyObject_VAR_HEAD,表示栈帧是一个变长对象,即每一次创建PyFrameObject对象大小可能是不一样的,那么变动在什么地方呢?首先每一个PyFrameObject对象都维护了一个PyCodeObject对象,而每一个PyCodeObject对象都会对应一个代码块(codeblock)。在编译一段代码块的时候,会计算这段代码块执行时所需要的栈空间的大小,这个栈空间大小存储在PyCodeObject对象的co_stacksize中。而不同的代码块所需要的栈空间是不同的,因此PyFrameObject的开头要有一个PyObject_VAR_HEAD对象。最后其实PyFrameObject里面的内存空间分为两部分,一部分是编译代码块需要的空间,另一部分是计算所需要的空间,我们也称之为"运行时栈"。

注意:x86机器上执行时的运行时栈不止包含了计算(还有别的)所需要的内存空间,但PyFrameObject对象的运行时栈则只包含计算所需要的内存空间,这一点务必注意。

在Python中获取栈帧,我们可以使用inspect模块。

importinspectdeff():#返回当前所在的栈帧,这个函数实际上是调用了sys._getframe(1)returninspect.currentframe()frame=f()print(frame)#print(type(frame))#我们看到栈帧的类型是,正如PyCodeObject对象的类型是一样。还是那句话,这两个类Python解释器没有暴露给我们,所以不可以直接使用。同理,还有Python的函数,类型是;模块,类型是,这些Python解释器都没有给我们提供,如果直接使用的话,那么frame、code、function、module只是几个没有定义的变量罢了,这些类我们只能通过这种间接的方式获取。

下面我们就来获取一下栈帧的成员属性

此外,异常处理也可以获取到栈帧。

deffoo():try:1/0exceptZeroDivisionError:importsys#exc_info返回一个三元组,分别是异常的类型、值、以及tracebackexc_type,exc_value,exc_tb=sys.exc_info()print(exc_type)#print(exc_value)#divisionbyzerprint(exc_tb)##调用exc_tb.tb_frame即可拿到异常对应的栈帧#另外这个exc_tb也可以通过exceptZeroDivisionErrorase;e.__traceback__的方式获取print(exc_tb.tb_frame.f_back)#>#因为foo是在模块级别、也就是最外层调用的,所以tb_frame是当前函数的栈帧、那么tb_frame.f_back就是整个模块对应的栈帧#那么再上一级的话,栈帧就是None了print(exc_tb.tb_frame.f_back.f_back)#Nonefoo()名字、作用域、名字空间我们在PyFrameObject里面看到了3个独立的名字空间:f_locals、f_globals、f_builtins。名字空间对于Python来说是一个非常重要的概念,整个Python虚拟机运行的机制和名字空间有着非常紧密的联系。并且在Python中,与命名空间这个概念紧密联系着的还有"名字"、"作用域"这些概念,下面就来剖析这些概念是如何实现的。

很早的时候我们就说过,Python中的变量在底层一个泛型指针PyObject*,而在Python的层面上来说,变量只是一个名字、或者说符号,用于和对象进行绑定的。变量的定义本质上就是建立名字和对象之间的约束关系,所以a=1这个赋值语句本质上就是将符号a和1对应的PyLongObject绑定起来,让我们通过a可以找到对应的PyLongObject。

除了变量赋值,函数定义、类定义也相当于定义变量,或者说完成名字和对象之间的绑定。

deffoo():passclassA():pass定义一个函数也相当于定义一个变量,会先根据函数体创建一个函数对象,然后将名字foo和函数对象绑定起来,所以函数名和函数体之间是分离的,同理类也是如此。

再有导入一个模块,也相当于定义一个变量。

importosimportos,相当于将名字os和模块对象绑定起来,通过os可以访问模块里面的属性。或者importnumpyasnp当中的as语句也相当于定义一个变量,将名字np和对应的模块对象绑定起来,以后就可以通过np这个名字去访问模块内部的属性了。

另外,当我们导入一个模块的时候,解释器是这么做的。比如:importos等价于os=__import__("os"),可以看到本质上还是一个赋值语句。

我们说赋值语句、函数定义、类定义、模块导入,本质上只是完成了名字和对象之间的绑定。而从概念上讲,我们实际上得到了一个name和obj这样的映射关系,通过name获取对应的obj,而它们的容身之所就是名字空间。而名字空间是通过PyDictObject对象实现的,这对于映射来说简直再适合不过了,所以字典在Python底层也是被大量使用的,因此是经过高度优化的。

但是一个模块内部,名字还存在可见性的问题,比如:

a=1deffoo():a=2print(a)#2foo()print(a)#1我们看到同一个变量名,打印的确实不同的值,说明指向了不同的对象。换句话说这两个变量是在不同的名字空间中被创建的,我们知道名字空间本质上是一个字典,如果两者是在同一个名字空间,那么由于字典的key的不重复性,那么当我进行a=2的时候,会把字典里面key为'a'的value给更新掉,但是在外面还是打印为1,这说明,两者所在的不是同一个名字空间。在不同的名字空间,打印的也就自然不是同一个a。

因此对于一个模块而言,内部是可能存在多个名字空间的,每一个名字空间都与一个作用域相对应。作用域就可以理解为一段程序的正文区域,在这个区域里面定义的变量是有作用的,然而一旦出了这个区域,就无效了。

对于作用域这个概念,至关重要的是要记住它仅仅是由源程序的文本所决定的。在Python中,一个变量在某个位置是否起作用,是由其在文本位置是否唯一决定的。因此,Python是具有静态作用域(词法作用域)的,而名字空间则是作用域的动态体现。一个由程序文本定义的作用域在Python运行时就会转化为一个名字空间、即一个PyDictObject对象。也就是说,在函数执行时,会为创建一个名字空间,这一点在以后剖析函数时会详细介绍。

我们之前说Python在对Python源代码进行编译的时候,对于代码中的每一个block,都会创建一个PyCodeObject与之对应。而当进入一个新的名字空间、或者说作用域时,我们就算是进入了一个新的block了。相信此刻你已经明白了,而且根据我们使用Python的经验,显然函数、类都是一个新的block,当Python运行的时候会它们创建各自的名字空间。

所以名字空间是名字、或者变量的上下文环境,名字的含义取决于命名空间。更具体的说,一个变量名对应的变量值什么,在Python中是不确定的,需要名字空间来决定。

位于同一个作用域中的代码可以直接访问作用域中出现的名字,即所谓的"直接访问",也就是不需要通过属性引用的访问修饰符:.。

classA:a=1classB:b=2print(A.a)#1print(b)#2比如:B里面想访问A里面的内容,比如通过A.属性的方式,表示通过A来获取A里面的属性。但是访问B的内容就不需要了,因为都是在同一个作用域,所以直接访问即可。

访问名字这样的行为被称为名字引用,名字引用的规则决定了Python程序的行为。

a=1deffoo():a=2print(a)#2foo()print(a)#1还是对于上面的代码,如果我们把函数里面的a=2给删掉,那么显然作用域里面已经没有a这个变量的,那么再执行程序会有什么后果呢?从Python层面来看,显然是会寻找外部的a。因此我们可以得到如下结论:

我们说函数、类是有自己的作用域的,但是模块对应的源文件本身也有相应的作用域。比如:

#a.pyname="夏色祭"age=-1deffoo():return123classA:pass由于这个文件本身也有自己的作用域(显然是global作用域),所以Python解释器在运行a.py这个文件的时候,也会为其创建一个名字空间,而显然这个名字空间就是global名字空间。它里面的变量是全局的,或者说是模块级别的,在当前的文件内可以直接访问。

而函数也会有一个作用域,这个作用域称为local作用域(对应local名字空间);同时Python自身还定义了一个最顶层的作用域,也就是builtin作用域(比如:dir、range、open都是builtin里面的)。这三个作用域在python2.2之前就存在了,所以那时候Python的作用域规则被称之为LGB规则:名字引用动作沿着local作用域(local名字空间)、global作用域(global名字空间)、builtin作用域(builtin名字空间)来查找对应的变量。

而获取名字空间,Python也提供了相应的内置函数:

对于global名字空间来说,它对应一个字典,并且这个字典是全局唯一的,全局变量都存储在这里面。

name="夏色祭"age=-1deffoo():name="神乐mea"age=38print(globals())#{...,'name':'夏色祭','age':-1,'foo':}里面的...表示省略了一部分输出,我们看到创建的全局变量都在里面了。而且foo也是一个变量,它指向一个函数对象,我们说foo也对应一个PyCodeObject。但是在解释到deffoo的时候,便会根据这个PyCodeObject对象创建一个PyFunctionObject对象,然后将foo和这个函数对象绑定起来。当我们调用foo的时候,会根据PyFunctionObject对象再创建PyFrameObject对象、然后执行,这些留在介绍函数的时候再细说。总之,我们看到foo也是一个全局变量,全局变量都在global名字空间中。

global名字空间全局唯一,它是程序运行时全局变量和与之绑定的对象的容身之所,你在任何一个地方都可以访问到global名字空间。正如,你在任何一个地方都可以访问相应的全局变量一样。

此外,我们说名字空间是一个字典,变量和变量指向的值会以键值对的形式存在里面。那么换句话说,如果我手动的往这个global名字空间里面添加一个键值对,是不是也等价于定义一个全局变量呢?

globals()["name"]="夏色祭"print(name)#夏色祭deff1():deff2():deff3():globals()["age"]=-1returnf3returnf2f1()()()print(age)#-1我们看到确实如此,通过往global名字空间里面插入一个键值对完全等价于定义一个全局变量。并且我们看到global名字空间是全局唯一的,你在任何地方调用globals()得到的都是global名字空间,正如你在任意地方都可以访问到全局变量一样。所以即使是在函数中向global名字空间中插入一个键值对,也等价于定义一个全局变量、并和对象绑定起来。

对于local名字空间来说,它也对应一个字典,显然这个字典是就不是全局唯一的了,每一个作用域都会对应自身的local名字空间。

deff():name="夏色祭"age=-1returnlocals()defg():name="神乐mea"age=38returnlocals()print(locals()==globals())#Trueprint(f())#{'name':'夏色祭','age':-1}print(g())#{'name':'神乐mea','age':38}显然对于模块来讲,它的local名字空间和global名字空间是一样的,也就是说模块对应的PyFrameObject对象里面的f_locals和f_globals指向的是同一个PyDictObject对象。

但是对于函数而言,局部名字空间和全局名字空间就不一样了。而调用locals也是获取自身的局部名字空间,因此不同的函数的local名字空间是不同的,而调用locals函数返回结果显然取决于调用它的位置。但是globals函数的调用结果是一样的,获取的都是global名字空间,这也符合"函数内找不到某个变量的时候会去找全局变量"这一结论。

所以我们说在函数里面查找一个变量,查找不到的话会找全局变量,全局变量再没有会查找内置变量。本质上就是按照自身的local空间、外层的global空间、内置的builtin空间的顺序进行查找。因此local空间会有很多个,因为每一个函数或者类都有自己的局部作用域,这个局部作用域就可以称之为该函数的local空间;但是global空间则全局唯一,因为该字典存储的是全局变量,无论你在什么地方,通过globals拿到的永远全局变量对应的名字空间,向该空间中添加键值对,等价于创建全局变量。

对于builtin命名空间,它也是一个字典。当local空间、global空间都没有的时候,会去builtin空间查找。

name="夏色祭"age=-1deff1():name="神乐mea"#local空间有"name"这个key,直接从局部名字空间获取print(name)#但是当前的local空间没有"age"这个key,所以会从global空间查找#从这里也能看出为什么函数也能访问到global空间了#如果函数内访问不到的话,那么它怎么能够在局部变量找不到的时候去找全局变量呢print(age)#但是local空间、global空间都没有"int"这个key,所以要去builtin空间查找了print(int)#"xxx"的话,三个空间都没有,那么结果只能是NameError了print(xxx)f1()"""神乐mea-1...File"D:/satori/1.py",line18,inf1print(xxx)NameError:name'xxx'isnotdefined"""问题来了,builtin名字空间如何获取呢?答案是通过builtins模块。

importbuiltins#我们调用int、str、list显然是从内置作用域、也就是builtin命名空间中查找的#即使我们只通过list也是可以的,因为local空间、global空间没有的话,最终会从builtin空间中查找,#但如果是builtins.list,那么就不兜圈子了,表示:"builtin空间,就从你这获取了"print(builtins.listislist)#Truebuiltins.dict=123#将builtin空间的dict改成123,那么此时获取的dict就是123,因为是从内置作用域中获取的print(dict+456)#579str=123#如果是str=123,等价于创建全局变量str=123,显然影响的是global空间,而查找显然也会先从global空间查找print(str)#123#但是此时不影响内置作用域print(builtins.str)#这里提一下Python2当中,while1比whileTrue要快,为什么?

因为True在Python2中不是关键字,所以它是可以作为变量名的,那么python在执行的时候就要先看local空间和global空间中有没有True这个变量,有的话使用我们定义的,没有的话再使用内置的True,而1是一个常量直接加载就可以。所以whileTrue它多了符号查找这一过程,但是在Python3中两者就等价了,因为True在python3中是一个关键字,所以会直接作为一个常量来加载。

这里再提一下函数的local空间

我们说:globals["name"]="夏色祭"等价于定义一个全局变量name="夏色祭",那么如果是在函数里面执行了locals["name"]="夏色祭",是不是等价于创建局部变量name="夏色祭"呢?

deff1():locals()["name"]="夏色祭"try:print(name)exceptExceptionase:print(e)f1()#name'name'isnotdefined我们说对于全局变量来讲,变量的创建是通过向字典添加键值对的方式实现的。因为全局变量会一直在变,需要使用字典来动态维护。但是对于函数来讲,内部的变量是通过静态方式访问的,因为其局部作用域中存在哪些变量在编译的时候就已经确定了,我们通过PyCodeObject的co_varnames即可获取内部都有哪些变量。

所以虽然我们说查找是按照LGB的方式查找,但是访问函数内部的变量其实是静态访问的,不过完全可以按照LGB的方式理解。

所以名字空间可以说是Python的灵魂,因为它规定了Python变量的作用域,使得Python对变量的查找变得非常清晰。

我们上面说的LGB是针对Python2.2之前的,那么Python2.2开始,由于引入了嵌套函数,显然最好的方式应该是内层函数找不到应该首先去外层函数找,而不是直接就跑到global空间、也就是全局里面找,那么此时的规则就是LEGB。

a=1deffoo():a=2defbar():print(a)returnbarf=foo()f()"""2"""调用f,实际上调用的是bar函数,最终输出的结果是2。如果按照LGB的规则来查找的话。bar函数的作用域没有a、那么应该到全局里面找,打印的应该是1才对。但是我们之前说了,作用域仅仅是由文本决定的,函数bar位于函数foo之内,所以bar函数定义的作用域内嵌与函数foo的作用域之内。换句话说,函数foo的作用域是函数bar的作用域的直接外围作用域,所以首先是从foo作用域里面找,如果没有那么再去全局里面找。而作用域和名字空间是对应的,所以最终打印了2。

因此在执行f=foo()的时候,会执行函数foo中的defbar():语句,这个时候Python会将a=2与函数bar对应的函数对象捆绑在一起,将捆绑之后的结果返回,这个捆绑起来的整体称之为闭包。

所以:闭包=内层函数+引用的外层作用域

这里显示的规则就是LEGB,其中E成为enclosing,代表直接外围作用域这个概念。

a=1deffoo():print(a)foo()"""1"""首先这段代码打印1,这显然是没有问题的,但是下面问题来了。

a=1deffoo():print(a)a=2foo()"""Traceback(mostrecentcalllast):File"C:/Users/satori/Desktop/love_minami/a.py",line8,infoo()File"C:/Users/satori/Desktop/love_minami/a.py",line5,infooprint(a)UnboundLocalError:localvariable'a'referencedbeforeassignment"""这里我仅仅是在print下面,在当前作用域又新建了一个变量a,结果就告诉我局部变量a在赋值之前就被引用了,这是怎么一回事,相信肯定有人为此困惑。

弄明白这个错误的根本就在于要深刻理解两点:

在编译的时候,因为存在a=2这条语句,所以知道函数中存在一个局部变量a,那么查找的时候就会在局部空间中查找。但是还没来得及赋值,就print(a)了,所以报错:局部变量a在赋值之前就被引用了。但如果没有a=2这条语句则不会报错,因为知道局部作用域中不存在a这个变量,所以会找全局变量a,从而打印1。

更有趣的东西隐藏在字节码当中,我们可以通过反汇编来查看一下:

importdisa=1defg():print(a)dis.dis(g)"""70LOAD_GLOBAL0(print)2LOAD_GLOBAL1(a)4CALL_FUNCTION16POP_TOP8LOAD_CONST0(None)10RETURN_VALUE"""deff():print(a)a=2dis.dis(f)"""120LOAD_GLOBAL0(print)2LOAD_FAST0(a)4CALL_FUNCTION16POP_TOP138LOAD_CONST1(2)10STORE_FAST0(a)12LOAD_CONST0(None)14RETURN_VALUE"""中间的序号代表字节码的偏移量,我们看第二条,g的字节码是LOAD_GLOBAL,意思是在global名字空间中查找,而f的字节码是LOAD_FAST,表示在local名字空间中查找名字。这说明Python采用了静态作用域策略,在编译的时候就已经知道了名字藏身于何处。

因此上面的例子表明,一旦作用域有了对某个名字的赋值操作,这个名字就会在作用域中可见,就会出现在local名字空间中,换句话说,就遮蔽了外层作用域中相同的名字。

但有时我们想要在函数里面修改全局变量呢?当然Python也为我们精心准备了global关键字,比如函数内部出现了globala,就表示我后面的a是全局的,你要到global名字空间里面找,不要在local空间里面找了

a=1defbar():deffoo():globalaa=2returnfoobar()()print(a)#2但是如果外层函数里面也出现了a,我们想找外层函数里面的a而不是全局的a,该怎么办呢?Python同样为我们准备了关键字:nonlocal,但是nonlocal的时候,必须确保自己是内层函数。

a=1defbar():a=2deffoo():nonlocalaa="xxx"returnfoobar()()print(a)#1#外界依旧是1属性引用与名称引用属性引用实质上也是一种名称引用,其本质都是到名称空间中去查找一个名称所引用的对象。这个就比较简单了,比如a.xxx,就是到a里面去找xxx,这个规则是不受LEGB作用域限制的,就是到a里面查找,有就是有、没有就是没有。

这个比较简单,但是有一点我们需要注意,那就是我们说属性查找会按照LEGB的规则,但是仅仅限制在自身所在的模块内。举个栗子:

#a.pyprint(name)#b.pyname="夏色祭"importa关于模块的导入我们后面系列中会详细说,总之目前在b.py里面执行的importa,你可以简单认为就是把a.py里面的内容拿过来执行一遍即可,所以这里相当于print(name)。

但是执行b.py的时候会提示变量name没有被定义,可是把a导进来的话,就相当于print(name),而我们上面也定义name这个变量了呀。显然,即使我们把a导入了进来,但是a.py里面的内容依旧是处于一个模块里面。而我们也说了,名称引用虽然是LEGB规则,但是无论如何都无法越过自身的模块的,print(name)是在a.py里面的,而变量name被定义在b.py中,所以是不可能跨过模块a的作用域去访问模块b里面的内容的。

所以模块整体也有一个作用域,就是该模块的全局作用域,每个模块是相互独立的。所以我们发现每个模块之间作用域还是划分的很清晰的,都是相互独立的。

关于模块,我们后续会详细说。总之通过.的方式本质上都是去指定的命名空间中查找对应的属性。

我们知道,自定义的类中如果没有__slots__,那么这个类的实例对象都会有一个属性字典。

classGirl:def__init__(self):self.name="夏色祭"self.age=-1g=Girl()print(g.__dict__)#{'name':'夏色祭','age':-1}#对于查找属性而言,也是去属性字典中查找print(g.name,g.__dict__["name"])#同理设置属性,也是更改对应的属性字典g.__dict__["gender"]="female"print(g.gender)#female当然模块也有属性字典,属性查找方面,本质上和上面的类的实例对象是一致的。

importbuiltinsprint(builtins.str)#print(builtins.__dict__["str"])#另外global空间里面是保存了builtin空间的指针的:

#globals()["__builtins__"]直接等价于importbuiltinsprint(globals()["__builtins__"])#importbuiltinsprint(builtins)##但我们说globals函数是在什么地方呢显然是在builtin空间中#所以print(globals()["__builtins__"].globals()["__builtins__"].globals()["__builtins__"].globals()["__builtins__"].globals()["__builtins__"].globals()["__builtins__"])#print(globals()["__builtins__"].globals()["__builtins__"].globals()["__builtins__"].globals()["__builtins__"].globals()["__builtins__"].globals()["__builtins__"].list("abc"))#['a','b','c']小结在Python中,一个名字(变量)可见范围由"作用域"决定,而作用域由语法静态划分,划分规则提炼如下:

与"作用域"相对应,Python在运行时借助PyDictObject对象保存作用域中的名字,构成动态的"名字空间"。这样的名字空间总共有4个:

当Python启动后,首先会进行运行时环境的初始化。注意这里的运行时环境,它和上面说的执行环境是不同的概念。运行时环境是一个全局的概念,而执行时环境是一个栈帧,是一个与某个codeblock相对应的概念。现在不清楚两者的区别不要紧,后面会详细介绍。关于运行时环境的初始化是一个非常复杂的过程,我们后面将用单独的一章进行剖析,这里就假设初始化动作已经完成,我们已经站在了Python虚拟机的门槛外面,只需要轻轻推动一下第一张骨牌,整个执行过程就像多米诺骨牌一样,一环扣一环地展开。

首先Python虚拟机执行PyCodeObject对象中字节码的代码为Python/ceval.c中,主要函数有两个:PyEval_EvalCodeEx是通用接口,一般用于函数这样带参数的执行场景;PyEval_EvalCode是更高层封装,用于模块等无参数的执行场景。

PyObject*PyEval_EvalCode(PyObject*co,PyObject*globals,PyObject*locals);PyObject*PyEval_EvalCodeEx(PyObject*_co,PyObject*globals,PyObject*locals,PyObject*const*args,intargcount,PyObject*const*kws,intkwcount,PyObject*const*defs,intdefcount,PyObject*kwdefs,PyObject*closure);这两个函数最终调用_PyEval_EvalCodeWithName函数,初始化栈帧对象并调用PyEval_EvalFrame和PyEval_EvalFrameEx函数进行处理。栈帧对象将贯穿代码对象执行的始终,负责维护执行时所需的一切上下文信息。而PyEval_EvalFrame和PyEval_EvalFrameEx函数最终调用_PyEval_EvalFrameDefault函数,虚拟机执行的秘密就藏在这里。

_PyEval_EvalFrameDefault函数是虚拟机运行的核心,这一个函数加上注释大概在3100行左右。可以说代码量非常大,但是逻辑并不难理解。

PyObject*_Py_HOT_FUNCTION_PyEval_EvalFrameDefault(PyFrameObject*f,intthrowflag){//......co=f->f_code;names=co->co_names;consts=co->co_consts;fastlocals=f->f_localsplus;freevars=f->f_localsplus+co->co_nlocals;//...... //逐条取出字节码来执行for(;;){if(_Py_atomic_load_relaxed(eval_breaker)){//读取下条字节码//字节码位于:f->f_code->co_code,偏移量由f->f_lasti决定opcode=_Py_OPCODE(*next_instr);//opcode是指令,我们说Python在Include/opcode.h中定义了121个指令if(opcode==SETUP_FINALLY||opcode==SETUP_WITH||opcode==BEFORE_ASYNC_WITH||opcode==YIELD_FROM){gotofast_next_opcode;}fast_next_opcode://......//判断该指令属于什么操作,然后执行相应的逻辑switch(opcode){//加载常量caseLOAD_CONST://....break;//加载名字caseLOAD_NAME://...break;//...}}}在这个执行架构中,对字节码一步一步的遍历是通过几个宏来实现的:

#defineINSTR_OFFSET()\(sizeof(_Py_CODEUNIT)*(int)(next_instr-first_instr))#defineNEXTOPARG()do{\_Py_CODEUNITword=*next_instr;\opcode=_Py_OPCODE(word);\oparg=_Py_OPARG(word);\next_instr++;\}while(0)Python的字节码有的是带有参数的,有的是没有参数的,而判断字节码是否带有参数是通过HAS_AGR这个宏来实现的。注意:对于不同的字节码指令,由于存在是否需要指令参数的区别,所以next_instr的位移可以是不同的,但无论如何,next_instr总是指向python下一条要执行的字节码。

Python在获得了一条字节码指令和其需要的参数指令之后,会对字节码利用switch进行判断,根据判断的结果选择不同的case语句,每一条指令都会对应一个case语句。在case语句中,就是Python对字节码指令的实现。所以这个switch语句非常的长,函数总共3000行左右,这个switch就占了2400行,因为指令有121个,比如:LOAD_CONST、LOAD_NAME、YIELD_FROM等等,而每一个指令都要对应一个case语句。

在成功执行完一条字节码指令和其需要的指令参数之后,Python的执行流程会跳转到fast_next_opcode处,或者for循环处。不管如何,Python接下来的动作就是获取下一条字节码指令和指令参数,完成对下一条指令的执行。通过for循环一条一条地遍历co_code中包含的所有字节码指令,然后交给for循环里面的switch语句,如此周而复始,最终完成了对Python程序的执行。

尽管只是简单的分析,但是相信大家也能了解Python执行引擎的大体框架,在Python的执行流程进入了那个巨大的for循环,取出第一条字节码交给里面的switch语句之后,第一张多米诺骨牌就已经被推倒,命运不可阻挡的降临了。一条接一条的字节码像潮水一样涌来,浩浩荡荡,横无际涯。

我们这里通过反编译的方式演示一下

指令分为很多种,我们这里就以简单的顺序执行为例,不涉及任何的跳转指令,看看Python是如何执行字节码的。

pi=3.14r=3area=pi*r**2对它们反编译之后,得到的字节码指令如下:

10LOAD_CONST0(3.14)2STORE_NAME0(pi)24LOAD_CONST1(3)6STORE_NAME1(r)38LOAD_NAME0(pi)10LOAD_NAME1(r)12LOAD_CONST2(2)14BINARY_POWER16BINARY_MULTIPLY18STORE_NAME2(area)20LOAD_CONST3(None)22RETURN_VALUE第一列是源代码的行号,第二列是指令的偏移量(或者说指令对应的索引),第三列是指令(或者操作码,它们在宏定义中代表整数),第四列表示指令参数(或者操作数)。

我们通过几张图展示一下上面的过程:

Python虚拟机刚开始执行时,准备好栈帧对象用于保存执行上下文,关系如下(省略部分信息)。另外,图中有地方画错了,图中的co_varnames应该改成co_names。我们说对于函数来说是通过co_varnames获取符号表(local空间里面局部变量的存储位置,一个静态数组),因为函数有哪些局部变量在编译时已经确定,会静态存储在符号表co_varnames中。但我们这里是对模块进行反编译、不是函数,而模块的符号是全局的,local空间和global空间是同一个,使用字典来维护,所以它的co_varnames是一个空元组。但co_names是可以获取到所有的符号的,因此这里把co_names理解为符号表即可,但我们知道全局变量是存在字典里面的。

由于next_instr初始状态指向字节码开头,虚拟机开始加载第一条字节码指令:0LOAD_CONST0(3.14)。字节码分为两部分,分别是操作码(opcode)和操作数(oparg)。LOAD_CONST指令表示将常量加载进运行时栈,常量下标由操作数给出。LOAD_CONST指令在_PyEval_EvalFrameDefault函数switch结构的一个case分支中实现:

接着虚拟机接着执行2STORE_NAME0(pi)指令,从符号表中获取索引为0的符号、即pi,然后将栈顶元素3.14弹出,再把符号"pi"和整数对象3.14绑定起来保存到local名字空间

同理,r=2对应的两条指令也是类似的。

然后8LOAD_NAME0(pi)、10LOAD_NAME1(r)、12LOAD_CONST2(2),表示将符号pi指向的值、符号r指向的值、常量2压入运行时栈。

然后14BINARY_POWER表示进行幂运算,16BINARY_MULTIPLY表示进行乘法运算。

其中,BINARY_POWER指令会从栈上弹出两个操作数(底数3和指数2)进行幂运算,并将结果9压回栈中;BINARY_MULTIPLY指令则进行乘积运算,步骤也是类似的。

caseTARGET(BINARY_POWER):{ //从栈顶弹出元素,这里是指数2PyObject*exp=POP();//我们看到这个是TOP,所以其实它不是弹出底数3,而是获取底数3,所以3这个元素依旧在栈里面PyObject*base=TOP(); //进行幂运算PyObject*res=PyNumber_Power(base,exp,Py_None);Py_DECREF(base);Py_DECREF(exp);//将幂运算的结果再设置回去,所以原来的3被计算之后的9给替换掉了SET_TOP(res);if(res==NULL)gotoerror;DISPATCH();}caseTARGET(BINARY_MULTIPLY):{//同理这里也是弹出元素9PyObject*right=POP();//获取元素3.14PyObject*left=TOP();//乘法运算PyObject*res=PyNumber_Multiply(left,right);Py_DECREF(left);Py_DECREF(right);//将运算的结果28.26将原来的3.14给替换掉SET_TOP(res);if(res==NULL)gotoerror;DISPATCH();}最终执行指令18STORE_NAME2(area),会从符号表中加载索引为2的符号、即area,再将"area"和浮点数28.26绑定起来放到名字空间中。

整体的执行流程便如上面几张图所示,当然字节码指令有很多,我们说它们定义在Include/opcode.h中,有121个。比如:除了LOAD_CONST、STORE_NAME之外,还有LOAD_FAST、LOAD_GLOBAL、STORE_FAST,以及if语句、循环语句所使用的跳转指令,运算使用的指令等等等等,这些在后面的系列中会慢慢遇到。

上面我们提到了一个运行时栈,我们说加载常量的时候会将常量(对象)从常量池中获取、并压入运行时栈,当计算或者使用变量保存的时候,会将其从栈里面弹出来。那么这个运行时栈所需要的空间都保存在什么地方呢?

PyFrameObject中有这么一个属性f_localsplus(可以回头看一下PyFrameObject的定义),我们说它是动态内存,用于"维护局部变量+cell对象集合+free对象集合+运行时栈所需要的空间",因此可以看出这段内存不仅仅使用来给栈使用的,还有别的对象使用。

并且这段连续的空间是由四部分组成,并且顺序是"局部变量"、"Cell对象"、"Free对象"、"运行时栈"。

这次我们深入了Python虚拟机源码,研究虚拟机执行字节码的全过程。虚拟机在执行PyCodeObject对象里面的字节码之前,需要先根据PyCodeObject对象创建栈帧对象(PyFrameObject),用于维护运行时的上下文信息。然后在PyFrameObject的基础上,执行字节码。

PyFrameObject关键信息包括:

栈帧对象通过f_back串成一个"栈帧调用链",与CPU栈帧调用链有异曲同工之妙。我们还借助inspect模块成功取得栈帧对象(底层是通过sys模块),并在此基础上输出整个函数调用链。

Python虚拟机的代码量不小,但是核心并不难理解,主要是_PyEval_EvalFrameDefault里面的一个巨大的for循环,准确的说for循环里面的那个巨型switch语句。其中的switch语句,case了每一个操作指令,当出现什么指令就执行什么操作。

THE END
1.人有三样东西是无法隐藏的,咳嗽,穷困和来自醉解心语Dream人有三样东西是无法隐藏的,咳嗽,穷困和爱,你想隐瞒却欲盖弥彰。人有三样东西是不该挥霍的,身体,金钱和爱,你想挥崔却得不偿失。人有三样东西是无法挽留的,时间,生命和爱,你想挽留却渐行渐远。人有三样东西是不该回忆的,灾难,死亡和爱,你想回忆却苦不堪言。 https://weibo.com/5344889714/O3K1AfuQE
2.家里三样东西不要留#日常唠嗑#经验分享#国学文化#关注我每天磉愁讨厌麻辣烫 0粉丝-1视频 关注 家里三样东西不要留 #日常唠嗑 #经验分享 #国学文化 #关注我每天分享不同的故事 637次播放2024-12-29发布 相关推荐 评论0打开App观看 00:07 #暖心正能量 1728次播放3天前 00:25 #看见音乐计划 #上热门话题全网最火歌曲 0次播放4天前 02:15 编舞:林芝雪域锅庄团队#优https://m.ixigua.com/video/7453747906772861477
3.人生有三样东西无法隐藏,咳嗽,贫穷和爱…真人出镜2024-04-04 00:57:29 0:00/0:00 速度 洗脑循环 Error: Hls is not supported. 视频加载失败https://www.163.com/v/video/VZTV90KO5.html
4.家里千万别留这三样东西,星座运势,风水,好看视频家里千万别留这三样东西 广巍百家故事 428粉丝 · 251个视频 关注 接下来播放自动播放 01:06 爷青回!王力宏金曲联唱《热爱30》中,这首《改变自己》引来了全场大合唱! 缘木不求娱 27万次播放 · 721次点赞 03:19 孙楠激情献唱《红旗飘飘》,高音振奋人心,展现浓浓的爱国情怀 C位看星光现场 39万次播放 · https://haokan.baidu.com/v?pd=wisenatural&vid=13840064722491259866
5.人有三样东西是无法隐藏的,咳嗽穷困和爱。。。有人说:“人有三样东西是无法隐藏的,咳嗽、穷困和爱。” 那么,看一个人爱不爱你,只要留心观察,你就可以找到答案,因为,真正的爱,根本藏不住,就像咳嗽一样。 你会发现,当一个人深爱你时,他会关注你的生活状态,会与你聊生活中最现实的柴米油盐,因为这关系到你的幸福指数。 https://www.meipian.cn/4zoarlro
6.稻盛和夫说:人有三样东西是无法隐瞒的,咳嗽贫困和爱;隐瞒却稻盛和夫他曾经说过:“人有三样东西是无法隐瞒的,咳嗽、贫困和爱;你想隐瞒却欲盖弥彰,人有三样东西是不能挥霍的,身体、金钱和爱。”这句话,深刻揭示了人性的复杂性和微妙性,同时也为我们提供了对生活的深刻理解。 首先,稻盛和夫提到的“无法隐瞒的三样东西”,分别是咳嗽、贫困和爱。咳嗽是无法掩饰的,因为https://www.360doc.cn/article/40098059_1102522004.html
7.这世界上有三样东西隐藏不了,闷热,烦躁,还有我要回家这世界上有三样东西隐藏不了,闷热,烦躁,还有我要回家 rt 这比学校开个风扇都跳闸,这还怎么过,还有比我这学校更坑的么https://m.douban.com/group/topic/40453050/
8.sdzapx.com/xxxr99756984.shtm一区二区三区在线播放 亚洲污污无码视频网站APP免费 啪啪啪操污污淫 娇妻虐乳扩张穿环 2022年最新《x7x7x7x7任意槽》1080P在线观看视频_ 超碰老女人 没有穿内裤被总裁?了一天漫画 日韩A V一区二区三区 人与牲动交XXⅩ?BBBB 欧美反差骚货后入乱伦大咪咪黑料猎奇大全 亚州砖码砖专区2022jdyy__http://sdzapx.com/xxxr99756984.shtm
9.《黑神话悟空》第三回小西天全收集物品攻略(上)第三回:夜生白露 开局打完小怪之后往前走,左手边下去再打两个怪之后开宝箱。湖里面记得捡 出来之后见到土地庙【披霜道】,土地庙有新的东西可以买 土地庙继续往前,说一下,第三回的小怪很多都是冻住装死的,所以本章的锁定很好用,防止被偷袭 此时进入剧情,剧情结束之后,直接按锁定能锁定的怪就是需要打的。注意这种https://zhuanlan.zhihu.com/p/717645311
10.英语词汇构造法则也就是说你一次记忆一组"1、4、5、7、9、6"数字和记忆"你好、书、篮球、手机、老鼠、饼干"一组词语和一次记忆"热闹非凡、快要下雨了、good night、天边有一团火烧云、pig、我想有个家"一段话,对大脑来说工作强度是一样的!即不管该组里是一个字母也好,还是一段话也罢,只要一次的记忆量在5-9组的范围内https://blog.csdn.net/dangerous_fire/article/details/54891214
11.《大理寺日志》漫画中“坑”的不完整总结梳理(截止235话)9. 来俊臣围攻丘神纪时,射箭偷袭丘的神秘人,到底是谁?与来俊臣、武皇到底关系几何?背后隐藏什么样的秘密? 95-97话,来俊臣奉武明空旨意,围攻丘神纪。就当丘准备反擒来时,却被背后的冷箭射伤,跪倒在地。射箭之人箭法很准,箭伤不致命,但皆是奔着关节处去的,可以让人无法动弹。此人随后离去,留下一个眼熟的绿色https://app.yibenmanhua.com/appreciate/id/223
12.红色警戒2怎么给狗狗升级(红色警戒怎么让狗巡逻)红警2的建筑总是比单位更经打的,而且还可以修理,所以不像单位那么容易被摧毁,而且建筑的攻击范围比一般的单位广,只要占领一个建筑在旁边起建筑```(巨炮打主基地很快的)也可以起兵营,造各种东西工程师,间谍等 2.熟悉地图哪些地图哪个地区有矿带,油井,要把他们抢住,并注意要防守好哦(白令海峡中间的油井区历来是https://www.wzmeili.com/yxgl/70668.html