内容

名称

perlinterp - Perl 解释器概述

描述

本文档概述了 Perl 解释器在 C 代码级别的工作原理,并提供了指向相关 C 源代码文件的指针。

解释器的组成部分

解释器的工作主要分为两个阶段:将代码编译成内部表示形式(即字节码),然后执行它。 "perlguts 中的编译代码" 解释了编译阶段的具体过程。

以下是 Perl 操作的简要分解

启动

操作从 perlmain.c 开始。(或 miniperlmain.c 用于 miniperl) 这是非常高级别的代码,可以放在一个屏幕上,它类似于 perlembed 中的代码;大部分实际操作发生在 perl.c

perlmain.cExtUtils::Miniperl 在 make 阶段从 miniperlmain.c 生成,因此您应该构建 perl 以便于理解。

首先,perlmain.c 分配一些内存并构建一个 Perl 解释器,如下所示

1 PERL_SYS_INIT3(&argc,&argv,&env);
2
3 if (!PL_do_undump) {
4     my_perl = perl_alloc();
5     if (!my_perl)
6         exit(1);
7     perl_construct(my_perl);
8     PL_perl_destruct_level = 0;
9 }

第 1 行是一个宏,其定义取决于您的操作系统。第 3 行引用了 `PL_do_undump`,这是一个全局变量 - Perl 中所有全局变量都以 `PL_` 开头。这告诉您当前运行的程序是否是用 `-u` 标志创建的,然后是 `undump`,这意味着在任何正常情况下它都将为假。

第 4 行调用 `perl.c` 中的一个函数来为 Perl 解释器分配内存。这是一个非常简单的函数,其核心代码如下所示

my_perl = (PerlInterpreter*)PerlMem_malloc(sizeof(PerlInterpreter));

在这里,您看到了 Perl 系统抽象的一个示例,我们将在后面看到:`PerlMem_malloc` 或者是您系统的 `malloc`,或者是在配置时选择该选项的情况下,`malloc.c` 中定义的 Perl 自身的 `malloc`。

接下来,在第 7 行,我们使用 `perl_construct`(也在 `perl.c` 中)构建解释器;这将设置 Perl 需要的所有特殊变量、堆栈等。

现在,我们将命令行选项传递给 Perl,并告诉它运行

if (!perl_parse(my_perl, xs_init, argc, argv, (char **)NULL))
    perl_run(my_perl);

exitstatus = perl_destruct(my_perl);

perl_free(my_perl);

`perl_parse` 实际上是 `S_parse_body`(在 `perl.c` 中定义)的包装器,它处理命令行选项,设置任何静态链接的 XS 模块,打开程序并调用 `yyparse` 来解析它。

解析

此阶段的目的是将 Perl 源代码转换为操作树。我们将在后面看到操作树的样子。严格来说,这里有三个步骤。

`yyparse`(解析器)位于 `perly.c` 中,但您最好阅读 `perly.y` 中的原始 YACC 输入。(是的,弗吉尼亚,Perl 确实有 YACC 语法!)解析器的任务是获取您的代码并“理解”它,将其分解成句子,确定哪些操作数与哪些运算符一起使用等等。

解析器得到了词法分析器的帮助,词法分析器将您的输入分成标记,并确定每个标记的类型:变量名、运算符、裸词、子例程、核心函数等等。词法分析器的主要入口点是 `yylex`,它及其关联的例程可以在 `toke.c` 中找到。Perl 与其他计算机语言有很大不同;它在某些情况下非常依赖上下文,很难确定某些内容是什么类型的标记,或者标记在哪里结束。因此,标记器和解析器之间存在很多交互,如果您不习惯的话,这可能会非常可怕。

当解析器理解 Perl 程序时,它会构建一个操作树,供解释器在执行期间执行。构建和链接各种操作的例程可以在 `op.c` 中找到,我们将在后面进行检查。

优化

现在解析阶段已经完成,生成的树表示 Perl 解释器执行我们的程序所需的操作。接下来,Perl 会对树进行一次预演,寻找优化机会:例如,3 + 4 这样的常量表达式将在此时计算,优化器还会查看是否可以将多个操作替换为单个操作。例如,为了获取变量 $foo,优化器会修改操作树,使用一个直接查找目标标量的函数,而不是获取 glob *foo 并查看其标量组件。主要的优化器是 op.c 中的 peep,许多操作都有自己的优化函数。

运行

现在我们终于可以开始了:我们已经编译了 Perl 字节码,剩下的就是运行它了。实际的执行由 run.c 中的 runops_standard 函数完成;更准确地说,它是由以下三行看似简单的代码完成的

while ((PL_op = PL_op->op_ppaddr(aTHX))) {
    PERL_ASYNC_CHECK();
}

你可能更习惯于 Perl 版本的代码

PERL_ASYNC_CHECK() while $Perl::op = &{$Perl::op->{function}};

好吧,也许不是。无论如何,每个操作都包含一个函数指针,它指定了实际执行该操作的函数。该函数将返回序列中的下一个操作 - 这允许像 if 这样的语句在运行时动态地选择下一个操作。PERL_ASYNC_CHECK 确保在需要时,信号等会中断执行。

实际调用的函数被称为 PP 代码,它们分布在四个文件中:pp_hot.c 包含“热”代码,这些代码使用频率最高,并且经过高度优化;pp_sys.c 包含所有特定于系统的函数;pp_ctl.c 包含实现控制结构(ifwhile 等)的函数;pp.c 包含所有其他内容。这些代码,如果你愿意的话,就是 Perl 内置函数和运算符的 C 代码。

请注意,每个 pp_ 函数都应该返回指向下一个操作的指针。对 Perl 子程序(和 eval 块)的调用在同一个 runops 循环中处理,并且不会在 C 栈上消耗额外的空间。例如,pp_entersubpp_entertry 只需将一个 CXt_SUBCXt_EVAL 块结构压入上下文栈,其中包含子程序调用或 eval 之后的 op 的地址。然后它们返回该子程序或 eval 块的第一个 op,因此执行继续进行。稍后,pp_leavesubpp_leavetry op 会弹出 CXt_SUBCXt_EVAL,从中检索返回 op,并将其返回。

异常处理

Perl 的异常处理(即 die 等)建立在低级的 setjmp()/longjmp() C 库函数之上。这些函数基本上提供了一种方法来捕获 CPU 的当前 PC 和 SP 寄存器,并在稍后恢复它们:即 longjmp() 在之前执行 setjmp() 的代码点继续执行,而 C 栈上任何更上层的代码都会丢失。(这就是为什么代码应该始终使用 SAVE_FOO 而不是在自动变量中保存值的原因。)

Perl 核心将 setjmp()longjmp() 封装在宏 JMPENV_PUSHJMPENV_JUMP 中。push 操作,以及设置 setjump(),会将一些临时状态存储在当前函数的局部结构体中(由 dJMPENV 分配)。特别是,它存储指向先前 JMPENV 结构体的指针,并将 PL_top_env 更新为指向最新的结构体,形成一个 JMPENV 状态链。push 和 jump 都可以在 perl -Dl 下输出调试信息。

Perl 内部的一个基本规则是,所有解释器退出都通过 JMPENV_JUMP() 实现。特别是

因此,Perl 解释器期望在任何时候都设置了一个合适的 JMPENV_PUSH(并且在 CPU 调用堆栈中的合适位置),可以捕获并处理 2 或 3 值的跳转;在 3 值跳转的情况下,启动一个新的 runops 循环来执行 PL_restartop 和所有剩余的 op(稍后将解释)。

Perl 解释器的入口点都提供了这样的功能。例如,perl_parse()、perl_run() 和 call_sv(cv, G_EVAL) 都包含类似于以下内容的代码:

{
    dJMPENV;
    JMPENV_PUSH(ret);
    switch (ret) {
    case 0:                     /* normal return from JMPENV_PUSH() */
      redo_body:
        CALLRUNOPS(aTHX);
        break;
    case 2:                     /* caught longjmp(2) - exit / die */
        break;
    case 3:                     /* caught longjmp(3) - eval { die } */
        PL_op = PL_restartop;
        goto redo_body;
    }

    JMPENV_POP;
}

像 Perl_runops_standard() 这样的 runops 循环(由 CALLRUNOPS() 设置)本质上只是一个简单的

while ((PL_op = PL_op->op_ppaddr(aTHX))) { 1; }

它调用与每个 op 关联的 pp() 函数,并依赖于该函数返回指向要执行的下一个 op 的指针。

除了在进入 Perl 解释器入口点设置捕获之外,您可能还期望 Perl 在诸如 pp_entertry() 之类的某些位置执行 JMPENV_PUSH(),这就在某些可捕获的操作执行之前。实际上,Perl 通常不会这样做。这样做的缺点是,对于嵌套或递归代码,例如

sub foo { my ($i) = @_; return if $i < 0; eval { foo(--$i) } }

那么 C 栈将很快因类似的条目对而溢出

...
#N+3 Perl_runops()
#N+2 Perl_pp_entertry()
#N+1 Perl_runops()
#N   Perl_pp_entertry()
...

相反,Perl 将其保护放在 runops 循环的调用者处。然后,您可以根据需要调用任意数量的嵌套子例程调用和 eval,所有这些都在一个 runops 循环内。如果发生异常,控制权将传递回循环的调用者,该调用者会立即使用 PL_restartop 作为要调用的下一个操作重新启动一个新循环。

因此,在正常操作中,如果有几个嵌套的 eval,将有多个 CXt_EVAL 上下文堆栈条目,但只有一个 runops 循环,由一个 JMPENV_PUSH 保护。每个捕获的 eval 将从堆栈中弹出下一个 CXt_EVAL,设置 PL_restartop,然后 longjmp() 返回 perl_run() 并继续。

但是,操作有时会在内部 runops 循环中执行,例如在 tie、sort 或重载代码中。在这种情况下,类似

sub FETCH { eval { die }; .... }

除非特殊处理,否则会导致 longjmp() 直接返回 perl_run() 中的保护,弹出两个 runops 循环 - 这显然是不正确的。避免这种情况的一种方法是让 tie 代码在内部 runops 循环中执行 FETCH 之前执行 JMPENV_PUSH,但出于效率原因,Perl 实际上只是使用 CATCH_SET(TRUE) 临时设置一个标志。此标志警告任何后续的 requireenterevalentertry 操作,调用者不再承诺代表他们捕获任何引发的异常。

这些操作检查此标志,如果为真,它们(通过 docatch())执行 JMPENV_PUSH 并启动一个新的 runops 循环来执行代码,而不是使用当前循环来执行代码。

因此,在退出上述 FETCH 中的 eval 块后,块后面的代码的执行仍在内部循环中进行(即由 pp_entertry() 建立的循环)。为了避免混淆,如果随后引发了进一步的异常,docatch() 会将 CXt_EVALJMPENV 级别与 PL_top_env 进行比较,如果它们不同,则只需重新抛出异常。这样,任何内部循环都会被弹出,异常将由期望它的级别正确处理。

以下是一个示例。

1: eval { tie @a, 'A' };
2: sub A::TIEARRAY {
3:     eval { die };
4:     die;
5: }

要运行此代码,将调用 perl_run(),它执行 JMPENV_PUSH(),然后进入一个 runops 循环。此循环在第 1 行执行 enterevaltie 操作,enterevalCXt_EVAL 推入上下文堆栈。

pp_tie() 函数执行 CATCH_SET(TRUE),然后启动第二个 runops 循环来执行 TIEARRAY() 的主体。当循环执行第 3 行的 entertry 操作时,CATCH_GET() 为真,因此 pp_entertry() 调用 docatch(),后者执行 JMPENV_PUSH 并启动第三个 runops 循环,该循环重新启动 pp_entertry(),然后执行 die 操作。此时,C 调用栈如下所示

#10 Perl_pp_die()
#9  Perl_runops()      # runops loop 3
#8  S_docatch()        # JMPENV level 2
#7  Perl_pp_entertry()
#6  Perl_runops()      # runops loop 2
#5  Perl_call_sv()
#4  Perl_pp_tie()
#3  Perl_runops()      # runops loop 1
#2  S_run_body()
#1  perl_run()         # JMPENV level 1
#0  main()

而上下文和数据栈,如 perl -Dstv 所示,如下所示

STACK 0: MAIN
  CX 0: BLOCK  =>
  CX 1: EVAL   => AV()  PV("A"\0)
  retop=leave
STACK 1: MAGIC
  CX 0: SUB    =>
  retop=(null)
  CX 1: EVAL   => *
retop=nextstate

die() 弹出上下文栈上的第一个 CXt_EVAL,从其中设置 PL_restartop,执行 JMPENV_JUMP(3),控制权返回到 docatch() 中设置的 JMPENV 级别。然后,这将启动另一个第三级 runops 级别,该级别执行第 4 行的 nextstatepushmarkdie 操作。在第二次调用 pp_die() 时,C 调用栈与上面完全相同,即使我们不再处于内部 eval 中。但是,上下文栈现在如下所示,即顶部 CXt_EVAL 已弹出

STACK 0: MAIN
  CX 0: BLOCK  =>
  CX 1: EVAL   => AV()  PV("A"\0)
  retop=leave
STACK 1: MAGIC
  CX 0: SUB    =>
  retop=(null)

第 4 行的 die() 将上下文栈弹出到 CXt_EVAL,使其变为

STACK 0: MAIN
  CX 0: BLOCK  =>

像往常一样,从 CXt_EVAL 中提取 PL_restartop,并执行 JMPENV_JUMP(3),这将 C 栈弹出到 docatch()

#8  S_docatch()        # JMPENV level 2
#7  Perl_pp_entertry()
#6  Perl_runops()      # runops loop 2
#5  Perl_call_sv()
#4  Perl_pp_tie()
#3  Perl_runops()      # runops loop 1
#2  S_run_body()
#1  perl_run()         # JMPENV level 1
#0  main()

在这种情况下,由于 CXt_EVAL 中记录的 JMPENV 级别与当前级别不同,因此 docatch() 只执行 JMPENV_JUMP(3) 重新抛出异常,C 栈展开到

#1  perl_run()         # JMPENV level 1
#0  main()

由于 PL_restartop 非空,因此 run_body() 启动一个新的 runops 循环,执行继续。

内部变量类型

你现在应该已经查看了 perlguts,它介绍了 Perl 的内部变量类型:SV、HV、AV 等等。如果没有,现在就去看一下。

这些变量不仅用于表示 Perl 空间变量,还用于表示代码中的任何常量,以及 Perl 内部的一些结构。例如,符号表是一个普通的 Perl 哈希。你的代码在被读入解析器时由一个 SV 表示;你调用的任何程序文件都是通过普通的 Perl 文件句柄打开的,等等。

核心 Devel::Peek 模块允许我们从 Perl 程序中检查 SV。例如,让我们看看 Perl 如何处理常量 "hello"

  % perl -MDevel::Peek -e 'Dump("hello")'
1 SV = PV(0xa041450) at 0xa04ecbc
2   REFCNT = 1
3   FLAGS = (POK,READONLY,pPOK)
4   PV = 0xa0484e0 "hello"\0
5   CUR = 5
6   LEN = 6

阅读 Devel::Peek 输出需要一些练习,所以让我们逐行进行。

第一行告诉我们正在查看一个位于内存地址 `0xa04ecbc` 的 SV。SV 本身结构非常简单,但它们包含指向更复杂结构的指针。在本例中,它是一个 PV,一个包含字符串值的结构,位于地址 `0xa041450`。第二行是引用计数;没有其他引用指向此数据,因此为 1。

第三行是此 SV 的标志 - 它可以作为 PV 使用,它是一个只读 SV(因为它是一个常量)并且数据在内部是一个 PV。接下来我们有字符串的内容,从地址 `0xa0484e0` 开始。

第五行告诉我们字符串的当前长度 - 请注意,这 **不** 包括空终止符。第六行不是字符串的长度,而是当前分配的缓冲区的长度;随着字符串的增长,Perl 会通过一个名为 `SvGROW` 的例程自动扩展可用存储空间。

你可以很容易地从 C 中获取这些数量中的任何一个;只需在代码片段中显示的字段名称前添加 `Sv`,你就会得到一个返回该值的宏:`SvCUR(sv)` 返回字符串的当前长度,`SvREFCOUNT(sv)` 返回引用计数,`SvPV(sv, len)` 返回字符串本身及其长度,等等。更多操作这些属性的宏可以在 perlguts 中找到。

让我们以一个操作 PV 的例子为例,来自 `sv_catpvn`,在 `sv.c` 中。

 1  void
 2  Perl_sv_catpvn(pTHX_ SV *sv, const char *ptr, STRLEN len)
 3  {
 4      STRLEN tlen;
 5      char *junk;

 6      junk = SvPV_force(sv, tlen);
 7      SvGROW(sv, tlen + len + 1);
 8      if (ptr == junk)
 9          ptr = SvPVX(sv);
10      Move(ptr,SvPVX(sv)+tlen,len,char);
11      SvCUR(sv) += len;
12      *SvEND(sv) = '\0';
13      (void)SvPOK_only_UTF8(sv);          /* validate pointer */
14      SvTAINT(sv);
15  }

这是一个将长度为 `len` 的字符串 `ptr` 添加到存储在 `sv` 中的 PV 末尾的函数。我们在第六行做的第一件事是确保 SV **具有** 一个有效的 PV,通过调用 `SvPV_force` 宏来强制一个 PV。作为副作用,`tlen` 被设置为 PV 的当前值,并且 PV 本身被返回到 `junk`。

在第七行,我们确保 SV 将有足够的空间来容纳旧字符串、新字符串和空终止符。如果 `LEN` 不够大,`SvGROW` 将为我们重新分配空间。

现在,如果 `junk` 与我们试图添加的字符串相同,我们可以直接从 SV 中获取字符串;`SvPVX` 是 SV 中 PV 的地址。

第十行执行实际的连接:`Move` 宏移动一块内存:我们将字符串 `ptr` 移动到 PV 的末尾 - 这是 PV 的开头加上它的当前长度。我们正在移动 `len` 个字节的 `char` 类型。完成此操作后,我们需要告诉 Perl 我们已经扩展了字符串,通过更改 `CUR` 来反映新的长度。`SvEND` 是一个宏,它为我们提供了字符串的结尾,因此需要是一个 `"\0"`。

第 13 行操作标志;由于我们已经更改了 PV,任何 IV 或 NV 值将不再有效:如果我们有 $a=10; $a.="6"; 我们不想使用 10 的旧 IV。SvPOK_only_utf8SvPOK_only 的一个特殊 UTF-8 感知版本,它是一个宏,它关闭 IOK 和 NOK 标志并打开 POK。最后的 SvTAINT 是一个宏,如果启用了 taint 模式,它会清洗受污染的数据。

AV 和 HV 更复杂,但 SV 是迄今为止最常见的变量类型。在了解了如何操作这些变量后,让我们继续看看操作树是如何构建的。

操作树

首先,什么是操作树?操作树是您程序的解析表示,正如我们在解析部分所见,它是 Perl 执行您的程序所经过的一系列操作,正如我们在 "运行" 中所见。

操作是 Perl 可以执行的基本操作:所有内置函数和运算符都是操作,并且有一系列操作处理解释器内部需要的概念 - 进入和离开块、结束语句、获取变量,等等。

操作树以两种方式连接:您可以想象它有两条“路线”,您可以遍历树的两种顺序。首先,解析顺序反映了解析器如何理解代码,其次,执行顺序告诉 Perl 以何种顺序执行操作。

检查操作树的最简单方法是在 Perl 完成解析后停止它,并让它转储树。这正是编译器后端 B::TerseB::Concise 和 CPAN 模块 <B::Debug 所做的。

让我们看看 Perl 如何看待 $a = $b + $c

 % perl -MO=Terse -e '$a=$b+$c'
 1  LISTOP (0x8179888) leave
 2      OP (0x81798b0) enter
 3      COP (0x8179850) nextstate
 4      BINOP (0x8179828) sassign
 5          BINOP (0x8179800) add [1]
 6              UNOP (0x81796e0) null [15]
 7                  SVOP (0x80fafe0) gvsv  GV (0x80fa4cc) *b
 8              UNOP (0x81797e0) null [15]
 9                  SVOP (0x8179700) gvsv  GV (0x80efeb0) *c
10          UNOP (0x816b4f0) null [15]
11              SVOP (0x816dcf0) gvsv  GV (0x80fa460) *a

让我们从中间的第 4 行开始。这是一个 BINOP,一个二元运算符,位于 0x8179828 位置。所讨论的特定运算符是 sassign - 标量赋值 - 您可以在 pp_hot.c 中的 pp_sassign 函数中找到实现它的代码。作为二元运算符,它有两个子节点:加法运算符,提供 $b+$c 的结果,位于第 5 行的最上面,而左侧位于第 10 行。

第 10 行是空操作:它什么也不做。它在那里做什么?如果您看到空操作,则表示在解析后某些内容已被优化掉。正如我们在 "优化" 中提到的,优化阶段有时会将两个操作转换为一个操作,例如在获取标量变量时。发生这种情况时,与其重写操作树并清理悬空指针,不如将冗余操作替换为空操作更容易。最初,树将如下所示

10          SVOP (0x816b4f0) rv2sv [15]
11              SVOP (0x816dcf0) gv  GV (0x80fa460) *a

也就是说,从主符号表中获取a条目,然后查看它的标量组件:gvsv(在pp_hot.c中为pp_gvsv)恰好同时执行这两件事。

右侧,从第 5 行开始,类似于我们刚刚看到的:我们有add操作符(pp_add,也在pp_hot.c中),将两个gvsv加在一起。

现在,这是关于什么的?

1  LISTOP (0x8179888) leave
2      OP (0x81798b0) enter
3      COP (0x8179850) nextstate

enterleave是作用域操作符,它们的工作是在每次进入和离开块时执行任何清理工作:词法变量被整理,未引用的变量被销毁,等等。每个程序都将拥有这前三行:leave是一个列表,它的子节点是块中的所有语句。语句由nextstate分隔,因此一个块是nextstate操作符的集合,每个语句要执行的操作是nextstate的子节点。enter是一个充当标记的单个操作符。

这就是 Perl 从上到下解析程序的方式。

 Program
    |
Statement
    |
    =
   / \
  /   \
 $a   +
     / \
   $b   $c

但是,不可能按照这种顺序执行这些操作:例如,您必须先找到$b$c的值,才能将它们加在一起。因此,另一个贯穿操作符树的线程是执行顺序:每个操作符都有一个op_next字段,它指向要运行的下一个操作符,因此按照这些指针可以告诉我们 perl 如何执行代码。我们可以使用B::Terseexec选项以这种顺序遍历树。

% perl -MO=Terse,exec -e '$a=$b+$c'
1  OP (0x8179928) enter
2  COP (0x81798c8) nextstate
3  SVOP (0x81796c8) gvsv  GV (0x80fa4d4) *b
4  SVOP (0x8179798) gvsv  GV (0x80efeb0) *c
5  BINOP (0x8179878) add [1]
6  SVOP (0x816dd38) gvsv  GV (0x80fa468) *a
7  BINOP (0x81798a0) sassign
8  LISTOP (0x8179900) leave

这对于人类来说可能更有意义:进入一个块,开始一个语句。获取$b$c的值,并将它们加在一起。找到$a,并将一个赋值给另一个。然后离开。

Perl 在解析过程中构建这些操作符树的方式可以通过检查toke.c(词法分析器)和perly.y(YACC 语法)来解开。让我们看看构建$a = $b + $c树的代码。

首先,我们将查看词法分析器中的Perl_yylex函数。我们想查找case 'x',其中 x 是操作符的第一个字符。(顺便说一句,当查找处理关键字的代码时,您需要搜索KEY_foo,其中“foo”是关键字。)以下是处理赋值的代码(有很多以=开头的操作符,因此出于简洁起见,大部分代码被省略了)

1    case '=':
2        s++;
         ... code that handles == => etc. and pod ...
3        pl_yylval.ival = 0;
4        OPERATOR(ASSIGNOP);

我们可以在第 4 行看到我们的令牌类型是ASSIGNOPOPERATOR是一个宏,在toke.c中定义,它返回令牌类型,以及其他内容)。和+

1     case '+':
2         {
3             const char tmp = *s++;
              ... code for ++ ...
4             if (PL_expect == XOPERATOR) {
                  ...
5                 Aop(OP_ADD);
6             }
              ...
7         }

第 4 行检查我们期望的令牌类型。Aop 返回一个令牌。如果你在 toke.c 中其他地方搜索 Aop,你会发现它返回一个 ADDOP 令牌。

现在我们知道了要在解析器中查找的两种令牌类型,让我们看看 perly.y 中我们需要构建 $a = $b + $c 树的部分代码。

1 term    :   term ASSIGNOP term
2                { $$ = newASSIGNOP(OPf_STACKED, $1, $2, $3); }
3         |   term ADDOP term
4                { $$ = newBINOP($2, 0, scalar($1), scalar($3)); }

如果你不习惯阅读 BNF 语法,它的工作原理如下:你从词法分析器那里获得一些东西,这些东西通常以大写字母表示。ADDOPASSIGNOP 是“终结符”的例子,因为它们无法再简化了。

语法,上面代码片段的第一行和第三行,告诉你如何构建更复杂的结构。这些复杂的结构,“非终结符”通常用小写字母表示。这里的 term 是一个非终结符,代表一个单一的表达式。

语法给你以下规则:如果你看到右边所有东西按顺序出现,你就可以创建左边冒号后面的东西。这被称为“归约”,解析的目标是完全归约输入。你可以用几种不同的方式进行归约,用竖线隔开:所以,term 后面跟着 = 后面跟着 term 可以构成一个 term,而 term 后面跟着 + 后面跟着 term 也可以构成一个 term

因此,如果你看到两个项之间有 =+,你可以将它们合并成一个表达式。当你这样做时,你执行下一行代码块中的代码:如果你看到 =,你将执行第 2 行的代码。如果你看到 +,你将执行第 4 行的代码。正是这段代码为操作树做出了贡献。

|   term ADDOP term
{ $$ = newBINOP($2, 0, scalar($1), scalar($3)); }

它的作用是创建一个新的二元操作符,并向它提供一些变量。这些变量引用令牌:$1 是输入中的第一个令牌,$2 是第二个令牌,以此类推——想想正则表达式的反向引用。$$ 是从这次归约中返回的操作符。因此,我们调用 newBINOP 来创建一个新的二元操作符。newBINOP 的第一个参数,一个在 op.c 中的函数,是操作符类型。它是一个加法操作符,所以我们希望类型为 ADDOP。我们可以直接指定它,但它就在输入中的第二个令牌中,所以我们使用 $2。第二个参数是操作符的标志:0 表示“没有特殊情况”。然后是需要添加的东西:我们表达式的左右两边,在标量上下文中。

创建操作符的函数,例如 newUNOPnewBINOP,在返回操作符之前会调用与每个操作符类型关联的“检查”函数。检查函数可以根据需要修改操作符,甚至用全新的操作符替换它。这些函数定义在 op.c 中,并以 Perl_ck_ 为前缀。您可以通过查看 regen/opcodes 来确定特定操作符类型使用的检查函数。例如,以 OP_ADD 为例。(OP_ADD 是解析器传递给 newBINOP 作为第一个参数的 toke.cAop(OP_ADD) 的令牌值。)以下是相关行

add             addition (+)            ck_null         IfsT2   S S

在这种情况下,检查函数是 Perl_ck_null,它什么也不做。让我们看看一个更有趣的例子

readline        <HANDLE>                ck_readline     t%      F?

以下是 op.c 中的函数

 1 OP *
 2 Perl_ck_readline(pTHX_ OP *o)
 3 {
 4     PERL_ARGS_ASSERT_CK_READLINE;
 5 
 6     if (o->op_flags & OPf_KIDS) {
 7          OP *kid = cLISTOPo->op_first;
 8          if (kid->op_type == OP_RV2GV)
 9              kid->op_private |= OPpALLOW_FAKE;
10     }
11     else {
12         OP * const newop
13             = newUNOP(OP_READLINE, 0, newGVOP(OP_GV, 0,
14                                               PL_argvgv));
15         op_free(o);
16         return newop;
17     }
18     return o;
19 }

一个特别有趣的方面是,如果操作符没有子节点(即 readline()<>),则操作符会被释放并用一个引用 *ARGV 的全新操作符替换(第 12-16 行)。

堆栈

当 Perl 执行类似 addop 的操作时,它如何将结果传递给下一个操作符?答案是通过使用堆栈。Perl 有许多堆栈来存储它正在处理的内容,我们将在这里介绍三个最重要的堆栈。

参数堆栈

参数使用参数堆栈 ST 传递给 PP 代码并从 PP 代码返回。处理参数的典型方法是从堆栈中弹出它们,根据需要处理它们,然后将结果压回堆栈。例如,余弦运算符就是这样工作的

NV value;
value = POPn;
value = Perl_cos(value);
XPUSHn(value);

当我们考虑 Perl 的宏时,我们将看到一个更棘手的例子。POPn 会给你栈顶 SV 的 NV(浮点数):cos($x) 中的 $x。然后我们计算余弦,并将结果作为 NV 推回栈中。XPUSHn 中的 X 表示如果需要,栈应该被扩展 - 在这里它不可能是必要的,因为我们知道栈上还有空间可以容纳一个项目,因为我们刚刚删除了一个!XPUSH* 宏至少保证了安全性。

或者,你可以直接操作栈:SP 会给你你部分栈中的第一个元素,TOP* 会给你栈顶的 SV/IV/NV/等等。例如,要对一个整数进行一元否定

SETi(-TOPi);

只需将栈顶条目的整数值设置为其否定值。

核心中的参数栈操作与 XSUB 中的操作完全相同 - 请参阅 perlxstutperlxsperlguts,以了解有关栈操作中使用的宏的更详细描述。

标记栈

我在上面说“你部分的栈”,因为 PP 代码不一定能获得整个栈:如果你的函数调用另一个函数,你只会希望暴露给被调用函数的参数,而不是(一定)让它访问你自己的数据。我们通过使用一个“虚拟”栈底来实现这一点,它暴露给每个函数。标记栈保存着每个函数可用的参数栈中位置的书签。例如,在处理一个绑定变量时(在内部,一个带有“P”魔力的东西),Perl 必须调用方法来访问绑定变量。但是,我们需要将暴露给方法的参数与暴露给原始函数的参数分开 - 存储或获取或任何它可能做的事情。以下是绑定 push 的大致实现方式;请参阅 av.c 中的 av_push

1	PUSHMARK(SP);
2	EXTEND(SP,2);
3	PUSHs(SvTIED_obj((SV*)av, mg));
4	PUSHs(val);
5	PUTBACK;
6	ENTER;
7	call_method("PUSH", G_SCALAR|G_DISCARD);
8	LEAVE;

让我们检查整个实现,作为练习

1	PUSHMARK(SP);

将栈指针的当前状态推入标记栈。这样,当我们完成向参数栈添加项目后,Perl 就会知道我们最近添加了多少个项目。

2	EXTEND(SP,2);
3	PUSHs(SvTIED_obj((SV*)av, mg));
4	PUSHs(val);

我们将向参数栈添加两个项目:当你有绑定数组时,PUSH 子例程会接收对象和要推入的值,而这正是我们在这里拥有的 - 绑定对象,通过 SvTIED_obj 获取,以及值,SV val

5	PUTBACK;

接下来,我们告诉 Perl 从我们的内部变量更新全局栈指针:dSP 只给了我们一个本地副本,而不是对全局的引用。

6	ENTER;
7	call_method("PUSH", G_SCALAR|G_DISCARD);
8	LEAVE;

ENTERLEAVE 用于定位代码块 - 它们确保所有变量都被清理,所有被局部化的变量都恢复其先前值,等等。可以将它们视为 Perl 块的 {}

为了实际执行魔法方法调用,我们必须在 Perl 空间中调用一个子例程:call_method 负责处理此操作,它在 perlcall 中有描述。我们以标量上下文调用 PUSH 方法,并将丢弃其返回值。call_method() 函数会移除标记栈的顶层元素,因此调用者无需清理。

保存栈

C 没有局部作用域的概念,因此 Perl 提供了它。我们已经看到 ENTERLEAVE 用作作用域括号;保存栈实现了 C 等效项,例如

{
    local $foo = 42;
    ...
}

有关如何使用保存栈,请参阅 "perlguts 中的局部化更改"

数百万个宏

你会注意到 Perl 源代码中充满了宏。有些人认为宏的普遍使用是最难理解的事情,而另一些人则认为它增加了清晰度。让我们举一个例子,一个简化版本的实现加法运算符的代码

 1  PP(pp_add)
 2  {
 3      dSP; dATARGET;
 4      tryAMAGICbin_MG(add_amg, AMGf_assign|AMGf_numeric);
 5      {
 6        dPOPTOPnnrl_ul;
 7        SETn( left + right );
 8        RETURN;
 9      }
10  }

这里每一行(当然除了括号之外)都包含一个宏。第一行设置了 Perl 预期的 PP 代码函数声明;第三行设置了参数栈和目标的变量声明,即操作的返回值。第四行尝试查看加法运算符是否被重载;如果是,则调用相应的子例程。

第六行是另一个变量声明 - 所有变量声明都以 d 开头 - 它从参数栈的顶部弹出两个 NV(因此为 nn),并将它们放入变量 rightleft 中,因此为 rl。它们是加法运算符的两个操作数。接下来,我们调用 SETn 将返回值的 NV 设置为两个值的加法结果。完成此操作后,我们返回 - RETURN 宏确保我们的返回值被正确处理,并将要运行的下一个运算符传递回主运行循环。

大多数这些宏都在 perlapi 中有解释,一些更重要的宏也在 perlxs 中有解释。请特别注意 "perlguts 中的背景和 MULTIPLICITY",以获取有关 [pad]THX_? 宏的信息。

进一步阅读

有关 Perl 内部机制的更多信息,请参阅 "perl 中的内部机制和 C 语言接口" 中列出的文档。