perlinterp - Perl 解释器概述
本文档概述了 Perl 解释器在 C 代码级别的工作原理,并提供了指向相关 C 源代码文件的指针。
解释器的工作主要分为两个阶段:将代码编译成内部表示形式(即字节码),然后执行它。 "perlguts 中的编译代码" 解释了编译阶段的具体过程。
以下是 Perl 操作的简要分解
操作从 perlmain.c 开始。(或 miniperlmain.c 用于 miniperl) 这是非常高级别的代码,可以放在一个屏幕上,它类似于 perlembed 中的代码;大部分实际操作发生在 perl.c 中
perlmain.c 由 ExtUtils::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 包含实现控制结构(if
、while
等)的函数;pp.c 包含所有其他内容。这些代码,如果你愿意的话,就是 Perl 内置函数和运算符的 C 代码。
请注意,每个 pp_
函数都应该返回指向下一个操作的指针。对 Perl 子程序(和 eval 块)的调用在同一个 runops 循环中处理,并且不会在 C 栈上消耗额外的空间。例如,pp_entersub
和 pp_entertry
只需将一个 CXt_SUB
或 CXt_EVAL
块结构压入上下文栈,其中包含子程序调用或 eval 之后的 op 的地址。然后它们返回该子程序或 eval 块的第一个 op,因此执行继续进行。稍后,pp_leavesub
或 pp_leavetry
op 会弹出 CXt_SUB
或 CXt_EVAL
,从中检索返回 op,并将其返回。
Perl 的异常处理(即 die
等)建立在低级的 setjmp()
/longjmp()
C 库函数之上。这些函数基本上提供了一种方法来捕获 CPU 的当前 PC 和 SP 寄存器,并在稍后恢复它们:即 longjmp()
在之前执行 setjmp()
的代码点继续执行,而 C 栈上任何更上层的代码都会丢失。(这就是为什么代码应该始终使用 SAVE_FOO
而不是在自动变量中保存值的原因。)
Perl 核心将 setjmp()
和 longjmp()
封装在宏 JMPENV_PUSH
和 JMPENV_JUMP
中。push 操作,以及设置 setjump()
,会将一些临时状态存储在当前函数的局部结构体中(由 dJMPENV
分配)。特别是,它存储指向先前 JMPENV
结构体的指针,并将 PL_top_env
更新为指向最新的结构体,形成一个 JMPENV
状态链。push 和 jump 都可以在 perl -Dl
下输出调试信息。
Perl 内部的一个基本规则是,所有解释器退出都通过 JMPENV_JUMP()
实现。特别是
级别 2:Perl 级别的 exit() 和内部 my_exit()
这些操作会展开所有堆栈,然后执行 JMPENV_JUMP(2)。
级别 3:Perl 级别的 die() 和内部 croak()
如果当前在 eval 中,这些操作会将上下文堆栈弹出到最近的 CXt_EVAL
帧,设置 $@
为适当的值,将 PL_restartop
设置为与该帧关联的 eval 之后的 op,然后执行 JMPENV_JUMP(3)。
否则,错误消息将打印到 STDERR
,然后将其视为退出:展开所有堆栈并执行 JMPENV_JUMP(2)。
级别 1:未使用
JMPENV_JUMP(1) 目前未使用,除了在 perl_run() 中。
级别 0:正常返回。
零值用于从 JMPENV_PUSH() 正常返回。
因此,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)
临时设置一个标志。此标志警告任何后续的 require
、entereval
或 entertry
操作,调用者不再承诺代表他们捕获任何引发的异常。
这些操作检查此标志,如果为真,它们(通过 docatch())执行 JMPENV_PUSH
并启动一个新的 runops 循环来执行代码,而不是使用当前循环来执行代码。
因此,在退出上述 FETCH
中的 eval 块后,块后面的代码的执行仍在内部循环中进行(即由 pp_entertry() 建立的循环)。为了避免混淆,如果随后引发了进一步的异常,docatch() 会将 CXt_EVAL
的 JMPENV
级别与 PL_top_env
进行比较,如果它们不同,则只需重新抛出异常。这样,任何内部循环都会被弹出,异常将由期望它的级别正确处理。
以下是一个示例。
1: eval { tie @a, 'A' };
2: sub A::TIEARRAY {
3: eval { die };
4: die;
5: }
要运行此代码,将调用 perl_run(),它执行 JMPENV_PUSH(),然后进入一个 runops 循环。此循环在第 1 行执行 entereval
和 tie
操作,entereval
将 CXt_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 行的 nextstate
、pushmark
和 die
操作。在第二次调用 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_utf8
是 SvPOK_only
的一个特殊 UTF-8 感知版本,它是一个宏,它关闭 IOK 和 NOK 标志并打开 POK。最后的 SvTAINT
是一个宏,如果启用了 taint 模式,它会清洗受污染的数据。
AV 和 HV 更复杂,但 SV 是迄今为止最常见的变量类型。在了解了如何操作这些变量后,让我们继续看看操作树是如何构建的。
首先,什么是操作树?操作树是您程序的解析表示,正如我们在解析部分所见,它是 Perl 执行您的程序所经过的一系列操作,正如我们在 "运行" 中所见。
操作是 Perl 可以执行的基本操作:所有内置函数和运算符都是操作,并且有一系列操作处理解释器内部需要的概念 - 进入和离开块、结束语句、获取变量,等等。
操作树以两种方式连接:您可以想象它有两条“路线”,您可以遍历树的两种顺序。首先,解析顺序反映了解析器如何理解代码,其次,执行顺序告诉 Perl 以何种顺序执行操作。
检查操作树的最简单方法是在 Perl 完成解析后停止它,并让它转储树。这正是编译器后端 B::Terse、B::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
enter
和leave
是作用域操作符,它们的工作是在每次进入和离开块时执行任何清理工作:词法变量被整理,未引用的变量被销毁,等等。每个程序都将拥有这前三行:leave
是一个列表,它的子节点是块中的所有语句。语句由nextstate
分隔,因此一个块是nextstate
操作符的集合,每个语句要执行的操作是nextstate
的子节点。enter
是一个充当标记的单个操作符。
这就是 Perl 从上到下解析程序的方式。
Program
|
Statement
|
=
/ \
/ \
$a +
/ \
$b $c
但是,不可能按照这种顺序执行这些操作:例如,您必须先找到$b
和$c
的值,才能将它们加在一起。因此,另一个贯穿操作符树的线程是执行顺序:每个操作符都有一个op_next
字段,它指向要运行的下一个操作符,因此按照这些指针可以告诉我们 perl 如何执行代码。我们可以使用B::Terse
的exec
选项以这种顺序遍历树。
% 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 行看到我们的令牌类型是ASSIGNOP
(OPERATOR
是一个宏,在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 语法,它的工作原理如下:你从词法分析器那里获得一些东西,这些东西通常以大写字母表示。ADDOP
和 ASSIGNOP
是“终结符”的例子,因为它们无法再简化了。
语法,上面代码片段的第一行和第三行,告诉你如何构建更复杂的结构。这些复杂的结构,“非终结符”通常用小写字母表示。这里的 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 表示“没有特殊情况”。然后是需要添加的东西:我们表达式的左右两边,在标量上下文中。
创建操作符的函数,例如 newUNOP
和 newBINOP
,在返回操作符之前会调用与每个操作符类型关联的“检查”函数。检查函数可以根据需要修改操作符,甚至用全新的操作符替换它。这些函数定义在 op.c 中,并以 Perl_ck_
为前缀。您可以通过查看 regen/opcodes 来确定特定操作符类型使用的检查函数。例如,以 OP_ADD
为例。(OP_ADD
是解析器传递给 newBINOP
作为第一个参数的 toke.c 中 Aop(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 中的操作完全相同 - 请参阅 perlxstut、perlxs 和 perlguts,以了解有关栈操作中使用的宏的更详细描述。
我在上面说“你部分的栈”,因为 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;
ENTER
和 LEAVE
用于定位代码块 - 它们确保所有变量都被清理,所有被局部化的变量都恢复其先前值,等等。可以将它们视为 Perl 块的 {
和 }
。
为了实际执行魔法方法调用,我们必须在 Perl 空间中调用一个子例程:call_method
负责处理此操作,它在 perlcall 中有描述。我们以标量上下文调用 PUSH
方法,并将丢弃其返回值。call_method()
函数会移除标记栈的顶层元素,因此调用者无需清理。
C 没有局部作用域的概念,因此 Perl 提供了它。我们已经看到 ENTER
和 LEAVE
用作作用域括号;保存栈实现了 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
),并将它们放入变量 right
和 left
中,因此为 rl
。它们是加法运算符的两个操作数。接下来,我们调用 SETn
将返回值的 NV 设置为两个值的加法结果。完成此操作后,我们返回 - RETURN
宏确保我们的返回值被正确处理,并将要运行的下一个运算符传递回主运行循环。
大多数这些宏都在 perlapi 中有解释,一些更重要的宏也在 perlxs 中有解释。请特别注意 "perlguts 中的背景和 MULTIPLICITY",以获取有关 [pad]THX_?
宏的信息。
有关 Perl 内部机制的更多信息,请参阅 "perl 中的内部机制和 C 语言接口" 中列出的文档。