目錄

名稱

perlinterp - Perl 詮釋器的概觀

說明

本文件概述 Perl 詮釋器在 C 程式碼層級的工作方式,並提供相關 C 原始碼檔案的連結。

詮釋器元素

詮釋器的任務包含兩個主要階段:將程式碼編譯成內部表示法或位元組碼,然後執行它。perlguts 中的「編譯程式碼」詳細說明編譯階段如何進行。

以下是 perl 操作的簡要說明

啟動

動作開始於 perlmain.c。(或 miniperlmain.c,針對 miniperl)這是非常高階的程式碼,足夠放在單一螢幕上,並且類似於 perlembed 中找到的程式碼;大部分實際動作發生於 perl.c

perlmain.c 是由 ExtUtils::Miniperl 在建立時間從 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 旗標建立 perl,然後 undump,這表示在任何正常情況下它都是錯誤的。

第 4 行呼叫 perl.c 中的函式,以分配 Perl 解譯器的記憶體。這是一個相當簡單的函式,其核心如下所示

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

在此您會看到 Perl 系統抽象的一個範例,我們稍後會看到:PerlMem_malloc 是您的系統 malloc,或如果您在設定時間選擇該選項,則為 malloc.c 中定義的 Perl 自有 malloc

接下來,在第 7 行,我們使用 perl.c 中的 perl_construct 建構解譯器;這會設定 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 實際上是 perl.c 中定義的 S_parse_body 的包裝函式,它會處理命令列選項、設定任何靜態連結的 XS 模組、開啟程式並呼叫 yyparse 以解析它。

解析

此階段的目標是取得 Perl 原始碼,並將其轉換為 op 樹狀結構。我們稍後會看到其中一個的樣子。嚴格來說,這裡發生了三件事。

yyparse,解析器,存在於 perly.c 中,儘管您最好閱讀 perly.y 中的原始 YACC 輸入。(是的,維吉尼亞,Perl YACC 語法!)解析器的任務是取得您的程式碼並「理解」它,將其拆分為句子,決定哪些運算元與哪些運算子搭配等。

解析器由詞法分析器高貴地協助,詞法分析器會將您的輸入分塊為代碼,並決定每個代碼的類型:變數名稱、運算子、裸字、子常式、核心函式等。詞法分析器的主要進入點是 yylex,它及其相關常式可以在 toke.c 中找到。Perl 與其他電腦語言不太相同;它有時高度依賴於背景,找出某個代碼的類型或代碼結束的位置可能會很棘手。因此,代碼化器和解析器之間有許多交互作用,如果您不習慣,可能會相當令人害怕。

當解析程式了解 Perl 程式時,它會建立一個運算樹,供直譯器在執行期間執行。建構和連結各種運算的常式會在 op.c 中找到,稍後會加以探討。

最佳化

現在,解析階段已完成,而完成的樹狀結構代表 Perl 直譯器執行我們的程式時需要執行的運算。接下來,Perl 會在樹狀結構上執行空跑,尋找最佳化:例如 3 + 4 等常數運算式會立即計算,而最佳化程式也會查看是否有任何多重運算可以替換為單一運算。例如,為了取得變數 $foo,最佳化程式會修改 op 樹狀結構,改用直接查詢問題標量的函式,而不是擷取 glob *foo 並查看標量組成部分。主要的最佳化程式是 op.c 中的 peep,而且許多 op 都擁有自己的最佳化函式。

執行

現在我們終於準備就緒:我們已編譯 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}};

好吧,也許不會。無論如何,每個 op 都包含一個函式指標,規定實際執行運算的函式。此函式會傳回序列中的下一個 op - 這允許在執行時動態選擇下一個 op 的運算,例如 ifPERL_ASYNC_CHECK 會確保訊號等事項在需要時中斷執行。

實際呼叫的函式稱為 PP 碼,它們分佈在四個檔案中:pp_hot.c 包含「熱門」碼,使用最頻繁且經過高度最佳化,pp_sys.c 包含所有特定於系統的函式,pp_ctl.c 包含實作控制結構的函式(例如 ifwhile 等),而 pp.c 則包含其他所有內容。如果你喜歡的話,這些就是 Perl 內建函式和運算子的 C 碼。

請注意,預期每個 pp_ 函式都會傳回下一個 op 的指標。對 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(),並將一些暫存狀態儲存在目前函式中的一個 struct 本機變數(由 dJMPENV 分配)。特別是,它會儲存一個指向先前 JMPENV struct 的指標,並更新 PL_top_env 以指向最新的 struct,形成一個 JMPENV 狀態鏈。push 和 jump 都可以在 perl -Dl 中輸出偵錯資訊。

perl 內部的一個基本規則是,所有直譯器退出都透過 JMPENV_JUMP() 達成。特別是

因此,perl 直譯器預期在任何時候都有適當的 JMPENV_PUSH 設定(且在 CPU 呼叫堆疊中的適當位置),可以捕捉和處理 2 或 3 值的跳躍;在 3 的情況下,開始新的 runops 迴圈來執行 PL_restartop 和所有剩下的操作(稍後會說明)。

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;
}

一個 runops 迴圈,例如 Perl_runops_standard()(由 CALLRUNOPS() 設定),其核心只是一個簡單的

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

它會呼叫與每個操作相關的 pp() 函式,依賴它回傳下一個要執行的操作的指標。

除了在 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 迴圈的呼叫方。然後,只要在一個 runops 迴圈中,就能呼叫任意數量的巢狀子常式呼叫和 evals。如果發生例外狀況,控制權會傳回迴圈的呼叫方,呼叫方會立即使用 PL_restartop 重新啟動一個新的迴圈,作為要呼叫的下一個 op。

因此,在有數個巢狀 evals 的正常運作中,將會有數個 CXt_EVAL 內容堆疊項目,但只有一個 runops 迴圈,由單一的 JMPENV_PUSH 防護。每個捕捉到的 eval 都會將下一個 CXt_EVAL 從堆疊中彈出,設定 PL_restartop,然後 longjmp() 回到 perl_run() 並繼續執行。

不過,op 有時會在內部 runops 迴圈中執行,例如在 tie、sort 或 overload 程式碼中。在此情況下,類似下列的程式碼

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

除非經過特殊處理,否則會導致 longjmp() 直接回到 perl_run() 中的防護措施,彈出兩個 runops 迴圈,這顯然是不正確的。避免這種情況的方法之一是,在內部 runops 迴圈中執行 FETCH 之前,tie 程式碼執行 JMPENV_PUSH,但基於效率考量,perl 實際上只使用 CATCH_SET(TRUE) 暫時設定一個旗標。這個旗標會警告任何後續的 requireenterevalentertry op,呼叫方不再承諾代為捕捉任何引發的例外狀況。

這些 op 會檢查這個旗標,如果為 true,它們會 (透過 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 op,其中 entereval 會將 CXt_EVAL 推入至內容堆疊。

pp_tie() 會執行 CATCH_SET(TRUE),然後啟動第二個 runops 迴圈,以執行 TIEARRAY() 的主體。當迴圈執行第 3 行的 entertry op 時,CATCH_GET() 為 true,因此 pp_entertry() 會呼叫 docatch(),它會執行 JMPENV_PUSH 並啟動第三個 runops 迴圈,重新啟動 pp_entertry(),然後執行 die op。此時,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 op。在呼叫第二個 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  =>

與往常一樣,PL_restartop 會從 CXt_EVAL 中萃取,並執行 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 hash。當您的程式碼讀入剖析器時,它會以 SV 表示;您呼叫的任何程式檔案都是透過普通的 Perl 檔案處理常式開啟的,依此類推。

核心 Devel::Peek 模組讓我們從 Perl 程式中檢查 SVs。例如,讓我們看看 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 輸出需要一點練習,所以讓我們逐行檢視。

第 1 行告訴我們,我們正在查看一個位於記憶體中 0xa04ecbc 的 SV。SV 本身是非常簡單的結構,但它們包含指向更複雜結構的指標。在這種情況下,它是一個 PV,一個儲存字串值的結構,位於位置 0xa041450。第 2 行是參考計數;沒有其他資料會參考此資料,所以它是 1。

第 3 行是此 SV 的旗標 - 將其用作 PV 是可以的,它是一個唯讀 SV(因為它是一個常數),而資料在內部是一個 PV。接下來,我們從位置 0xa0484e0 開始取得字串的內容。

第 5 行給我們字串的目前長度 - 注意,這包含 null 終止符。第 6 行不是字串的長度,而是目前分配的緩衝區的長度;當字串增長時,Perl 會自動透過一個稱為 SvGROW 的常式來擴充可用儲存空間。

您可以非常輕鬆地從 C 取得這些值中的任何一個;只要將 Sv 加到片段中顯示的欄位名稱,您就會得到一個會傳回值的巨集:SvCUR(sv) 傳回字串的目前長度,SvREFCOUNT(sv) 傳回參考計數,SvPV(sv, len) 傳回字串本身及其長度,依此類推。可以在 perlguts 中找到更多用於操作這些屬性的巨集。

讓我們舉一個操作 PV 的範例,來自 sv.c 中的 sv_catpvn

 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 的結尾。我們在第 6 行執行的第一件事是透過呼叫 SvPV_force 巨集來強制使用 PV,以確保 SV 有效的 PV。作為副作用,tlen 會設定為 PV 的目前值,而 PV 本身會傳回給 junk

在第 7 行,我們確保 SV 有足夠的空間容納舊字串、新字串和 null 終止符。如果 LEN 不夠大,SvGROW 會為我們重新分配空間。

現在,如果 junk 與我們嘗試新增的字串相同,我們可以從 SV 直接擷取字串;SvPVX 是 SV 中 PV 的位址。

第 10 行執行實際的串接:Move 巨集移動一段記憶體:我們將字串 ptr 移動到 PV 的結尾,也就是 PV 的開頭加上其目前長度。我們移動 len 位元組的 char 類型。執行此操作後,我們需要告訴 Perl 我們已延伸字串,方法是變更 CUR 以反映新長度。SvEND 是提供字串結尾的巨集,因此需要為 "\0"

第 13 行處理旗標;由於我們已變更 PV,任何 IV 或 NV 值都將不再有效:如果我們有 $a=10; $a.="6"; 我們不想使用舊的 IV 10。SvPOK_only_utf8SvPOK_only 的特殊 UTF-8 識別版本,此巨集會關閉 IOK 和 NOK 旗標,並開啟 POK。最後的 SvTAINT 是在開啟 taint 模式時清洗 taint 資料的巨集。

AV 和 HV 較為複雜,但 SV 迄今為止是最常見的變數類型。在了解我們如何處理這些變數後,讓我們繼續了解 op 樹的建構方式。

OP 樹

首先,op 樹是什麼?op 樹是程式碼的剖析表示,如我們在剖析部分中所見,而且是 Perl 執行程式碼時會經歷的作業順序,如我們在 "執行" 中所見。

op 是 Perl 可以執行的基本作業:所有內建函數和運算子都是 op,而且有一系列 op 處理直譯器內部需要的概念,例如進入和離開區塊、結束陳述式、擷取變數,等等。

op 樹以兩種方式連接:你可以想像其中有兩條「路徑」,你可以用兩種順序來遍歷樹。首先,剖析順序反映剖析器如何理解程式碼,其次,執行順序告訴 perl 以什麼順序執行作業。

檢查 op 樹最簡單的方法是在 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_sassign 中找到實作它的程式碼,位於 pp_hot.c 中。作為一個二元運算子,它有兩個子節點:加法運算子,提供 $b+$c 的結果,位於第 5 行的最上方,而左邊則位於第 10 行。

第 10 行是空運算:這完全沒有作用。它在那裡做什麼?如果你看到空運算,這表示在剖析後某些東西已被最佳化。正如我們在 "最佳化" 中提到的,最佳化階段有時會將兩個運算轉換為一個運算,例如在擷取標量變數時。當這發生時,它會直接用空運算取代多餘的運算,而不是重寫 op 樹和清除懸空指標。原本,樹會看起來像這樣

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

也就是說,從主符號表中擷取 a 條目,然後查看它的標量組成:gvsvpp_hot.c 中的 pp_gvsv)碰巧同時執行這兩件事。

右邊,從第 5 行開始,類似於我們剛剛看到的:我們有 add op(pp_hot.c 中的 pp_add)將兩個 gvsv 加在一起。

現在,這是怎麼回事?

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

enterleave 是作用域 op,它們的工作是在每次進入和離開區塊時執行任何家務事:整理詞彙變數、銷毀未引用的變數等等。每個程式都會有前三行:leave 是個清單,它的子節點是區塊中的所有陳述式。陳述式以 nextstate 為分隔,因此區塊是 nextstate op 的集合,而要為每個陳述式執行的 op 則為 nextstate 的子節點。enter 是作為標記運作的單一 op。

這就是 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 和 YACC 語法 perly.y 來解開。我們來看一下建構 $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

因此,如果你看到兩個 term 之間有 =+,你可以將它們轉換成單一運算式。執行此操作時,你會執行下一行區塊中的程式碼:如果你看到 =,你會執行第 2 行的程式碼。如果你看到 +,你會執行第 4 行的程式碼。這段程式碼會貢獻到 op 樹。

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

這段程式碼會建立新的二元 op,並提供數個變數給它。這些變數會參照代碼:$1 是輸入中的第一個代碼,$2 是第二個代碼,依此類推 - 思考正規表示式的反向參考。$$ 是從此簡約回傳的 op。因此,我們呼叫 newBINOP 來建立新的二元運算子。傳遞給 newBINOP 的第一個參數(op.c 中的函式)是 op 類型。它是加法運算子,因此我們希望類型為 ADDOP。我們可以直接指定這個類型,但它就在輸入中的第二個代碼中,因此我們使用 $2。第二個參數是 op 的旗標:0 表示「沒有特殊情況」。然後是需要新增的項目:運算式的左方和右方,在標量內容中。

建立 op 的函式(函式名稱類似於 newUNOPnewBINOP)會在回傳 op 之前,呼叫與每個 op 類型相關聯的「檢查」函式。檢查函式可以根據需要來修改 op,甚至用全新的 op 取代它。這些函式定義在 op.c 中,並具有 Perl_ck_ 前置詞。你可以查看 regen/opcodes 來找出特定 op 類型使用哪個檢查函式。例如,取用 OP_ADD。(OP_ADDtoke.cAop(OP_ADD) 的代碼值,剖析器會將它作為第一個引數傳遞給 newBINOP。)以下是相關行

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 }

一個特別有趣的方面是,如果 op 沒有子代(即 readline()<>),則會釋放 op 並用一個完全新的 op 取代它,該 op 引用 *ARGV(第 12-16 行)。

堆疊

當 perl 執行類似 addop 的操作時,它是如何將其結果傳遞給下一個 op 的?答案是,透過使用堆疊。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 中的「Localizing changes」,了解如何使用儲存堆疊。

數百萬個巨集

您會注意到 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 程式碼的函式宣告;第 3 行設定引數堆疊和目標(運算的傳回值)的變數宣告。第 4 行嘗試查看加法運算是否已超載;如果是,則會呼叫適當的子常式。

第 6 行是另一個變數宣告 - 所有變數宣告都以 d 開頭 - 它會從引數堆疊的頂端彈出兩個 NV(因此為 nn),並將它們放入變數 rightleft,因此為 rl。這些是加法運算的兩個運算元。接著,我們呼叫 SETn 將傳回值的 NV 設定為加入兩個值的結果。完成後,我們傳回 - RETURN 巨集確保我們的傳回值已妥善處理,並且我們將下一個要執行的運算子傳回主執行迴圈。

這些巨集大多在 perlapi 中說明,而一些較重要的巨集也在 perlxs 中說明。特別注意 perlguts 中的「背景和 MULTIPLICITY」,以取得關於 [pad]THX_? 巨集的資訊。

進一步閱讀

如需有關 Perl 內部結構的更多資訊,請參閱 perl 中的「內部結構和 C 語言介面」 中列出的文件。