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 的運算,例如 if
。PERL_ASYNC_CHECK
會確保訊號等事項在需要時中斷執行。
實際呼叫的函式稱為 PP 碼,它們分佈在四個檔案中:pp_hot.c 包含「熱門」碼,使用最頻繁且經過高度最佳化,pp_sys.c 包含所有特定於系統的函式,pp_ctl.c 包含實作控制結構的函式(例如 if
、while
等),而 pp.c 則包含其他所有內容。如果你喜歡的話,這些就是 Perl 內建函式和運算子的 C 碼。
請注意,預期每個 pp_
函式都會傳回下一個 op 的指標。對 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()
,並將一些暫存狀態儲存在目前函式中的一個 struct 本機變數(由 dJMPENV
分配)。特別是,它會儲存一個指向先前 JMPENV
struct 的指標,並更新 PL_top_env
以指向最新的 struct,形成一個 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 之後的操作,然後執行 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
和所有剩下的操作(稍後會說明)。
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)
暫時設定一個旗標。這個旗標會警告任何後續的 require
、entereval
或 entertry
op,呼叫方不再承諾代為捕捉任何引發的例外狀況。
這些 op 會檢查這個旗標,如果為 true,它們會 (透過 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
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 行的 nextstate
、pushmark
和 die
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_utf8
是 SvPOK_only
的特殊 UTF-8 識別版本,此巨集會關閉 IOK 和 NOK 旗標,並開啟 POK。最後的 SvTAINT
是在開啟 taint 模式時清洗 taint 資料的巨集。
AV 和 HV 較為複雜,但 SV 迄今為止是最常見的變數類型。在了解我們如何處理這些變數後,讓我們繼續了解 op 樹的建構方式。
首先,op 樹是什麼?op 樹是程式碼的剖析表示,如我們在剖析部分中所見,而且是 Perl 執行程式碼時會經歷的作業順序,如我們在 "執行" 中所見。
op 是 Perl 可以執行的基本作業:所有內建函數和運算子都是 op,而且有一系列 op 處理直譯器內部需要的概念,例如進入和離開區塊、結束陳述式、擷取變數,等等。
op 樹以兩種方式連接:你可以想像其中有兩條「路徑」,你可以用兩種順序來遍歷樹。首先,剖析順序反映剖析器如何理解程式碼,其次,執行順序告訴 perl 以什麼順序執行作業。
檢查 op 樹最簡單的方法是在 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_sassign
中找到實作它的程式碼,位於 pp_hot.c 中。作為一個二元運算子,它有兩個子節點:加法運算子,提供 $b+$c
的結果,位於第 5 行的最上方,而左邊則位於第 10 行。
第 10 行是空運算:這完全沒有作用。它在那裡做什麼?如果你看到空運算,這表示在剖析後某些東西已被最佳化。正如我們在 "最佳化" 中提到的,最佳化階段有時會將兩個運算轉換為一個運算,例如在擷取標量變數時。當這發生時,它會直接用空運算取代多餘的運算,而不是重寫 op 樹和清除懸空指標。原本,樹會看起來像這樣
10 SVOP (0x816b4f0) rv2sv [15]
11 SVOP (0x816dcf0) gv GV (0x80fa460) *a
也就是說,從主符號表中擷取 a
條目,然後查看它的標量組成:gvsv
(pp_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
enter
和 leave
是作用域 op,它們的工作是在每次進入和離開區塊時執行任何家務事:整理詞彙變數、銷毀未引用的變數等等。每個程式都會有前三行:leave
是個清單,它的子節點是區塊中的所有陳述式。陳述式以 nextstate
為分隔,因此區塊是 nextstate
op 的集合,而要為每個陳述式執行的 op 則為 nextstate
的子節點。enter
是作為標記運作的單一 op。
這就是 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 和 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 行看到我們的代幣類型是 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
。
因此,如果你看到兩個 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 的函式(函式名稱類似於 newUNOP
和 newBINOP
)會在回傳 op 之前,呼叫與每個 op 類型相關聯的「檢查」函式。檢查函式可以根據需要來修改 op,甚至用全新的 op 取代它。這些函式定義在 op.c 中,並具有 Perl_ck_
前置詞。你可以查看 regen/opcodes 來找出特定 op 類型使用哪個檢查函式。例如,取用 OP_ADD
。(OP_ADD
是 toke.c 中 Aop(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 中的完全相同 - 請參閱 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 中的「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
),並將它們放入變數 right
和 left
,因此為 rl
。這些是加法運算的兩個運算元。接著,我們呼叫 SETn
將傳回值的 NV 設定為加入兩個值的結果。完成後,我們傳回 - RETURN
巨集確保我們的傳回值已妥善處理,並且我們將下一個要執行的運算子傳回主執行迴圈。
這些巨集大多在 perlapi 中說明,而一些較重要的巨集也在 perlxs 中說明。特別注意 perlguts 中的「背景和 MULTIPLICITY」,以取得關於 [pad]THX_?
巨集的資訊。
如需有關 Perl 內部結構的更多資訊,請參閱 perl 中的「內部結構和 C 語言介面」 中列出的文件。