C2Forth編譯程式初步分析及修改

本文是擷取自「中華民國符式語言協會」之舊版網站,文章版權仍屬原作者所有。

如有侵犯其權利,敬請告知,將迅速將網文撤下。

路客

概觀

輸出程式碼分析

程式編譯

這個C2F compiler 是修改自著名的 LCC compiler 而來,作者是在 Redmond 的 Microsoft Research的兩位 PH.D 所寫的,其中一位在普林斯頓大學的計算機科學系當過教授。兩人的經歷都相當的顯赫。這個程式的原始碼可以從普林斯頓下載 : ftp://ftp.cs.princeton.edu/pub/packages/lcc,該 compiler 支援完整的 ANSI C,是一個 re-targetable 的 compiler ,也就是說,只要修改它的 code generator 就可以用來發展不同 CPU 的 cross compiler。

這個版本可以用 Borland C++ 3.1 來重新建立,筆者試著重新 compile 過,完全沒問題。如此便可利用 Borland C++ 3.1 的 IDE 環境來追蹤修改這個程式。Borland C++ 3.1 是 16bit 的 C compiler,不過這和 target 系統的 word size 並沒有關系,只是說 C2F 本身是一個 16bit 的 real-mode DOS 程式。

C2F假設 Forth 的執行平台是 spforth,因此產生出來的程式並不是完全標準的 ANSI Forth,真的要拿來應用的話可能還得作些修改,因為它使用了一些 SPForth 的特性。

由於原始的 C2F 中沒有把 lcc 中的前置處理器(cpp)加入,因為這個部分完全是 C 的問題,因此筆者直接從 lcc 4.1 中把 cpp.exe copy 過來用,如此就可以處理巨集和條件編譯之類的前置命令,如 #include #define , #if ... #else ... #endif 之流。

SP-FORTH 簡介

SP-FORTH (以下簡稱SPF)和筆者個人的Common Forth 系統同屬 i386 上的Macro expansion subroutine threaded Forth system,然而 SPF 更加入了許多 machine code level 的最佳化(見SPF原始碼的 src/macroopt),將經常出現的 code sequence 作最佳化,以加快執行速度,當然,也因此要寫它的 discompiler 就會非常困難。

另外,值得一提的是,除了註解是俄文之外,還有些範例程式也是用俄文寫成的。幸好,KERNEL 沒有被俄文化,不然就很難了解了。

C2F 程式執行

如同 C2F 根目錄下的範例 tst.bat 所展示的:

C2F.EXE -target=bytecode filename.c > filename.f

如果要先經過前置處理的話,先:

CPP.EXE filename.c > filename$$$.c

然後再用 C2F 編譯 filename$$$.c,讀者可以自己寫一個簡單的批次檔來作這件事。最後再啟動 SPF 來執行結果的 filename.f。

LCC 的字彙分析器(lexical analyzer)和語法分析器(parser)都是「純手工打造」的,而不是如一般的 compiler 是由程式產生器 LEX/FLEX 或 YACC/BISON 「機械打造」的。而且,他們用手寫的「遞迴轉換」(recursive descent)parser 在程式中佔的分量很少,大多數的重點放在程式的「語意分析」和「最佳化」上,然而,它的最佳化也只侷限於 local optimization,並沒有作 global optimization。

程式會被編譯成一個中間語言叫 DAG language,這個他們自訂的中間語言,結構非常簡單,是以 RISC 的精神來設計的,總共只有33 個「指令」,包括了加減乘除、位元、移位、資料形態轉換、記憶體存取、條件跳躍、無條件跳躍、副程式呼叫、返回。如我們意料中的,C2F編譯的秘密就是用 Forth 來模擬 DAG,先設計相對於 DAG 指令的 Forth 碼,就可以完成大部分的工作。主要的問題在跳躍和標記(LABEL)的處理,因為有些向前參考的跳躍動作,因此 C2F 的設計者就寫了一個 ForC.f 來做這些動作。

我們以 C2F 的範例 TST.C -> TST.F 為例說明,函式 ff4() :

int ff4(float xx, float yy, char vv)

{ int zz;

while(vv--) zz+=xx+6.55;

return zz+yy;

};

編譯結果:

: ff4 [ 8 28 BIG_PROC ] >R F>R F>R

0 &F F@ 0 &F F!

24 &F @ 24 &F C!

VJUMP $3

VLABEL $2

0 &L @ DS>F

0 &F F@ 6.55e F+ F+ F>DS 0 &L !

VLABEL $3

24 &F C@ C>S 4 &L !

4 &L @ 1 - 24 &F C! 4 &L @ 0 = IF VJUMP $2 THEN

0 &L @ DS>F 12 &F F@ F+ F>DS

VLABEL $1 END_PROC ;

三、跳躍:

一、參數傳遞和處理:

這部分可能是C to Forth轉換後,影響執行效能的最大因素,因為這是 stack machine 和register machine (load/store machine) 最大的差異所在。在 C2F 的處理上,並未對此部分作任何特殊的設計,它用最直接的暴力法(brute force)來完成,因此執行效能大打折扣,如果 Forth 程式執行時間是純組合語言的2 倍,而 C to Forth 又是純 Forth 的三倍,則執行時間就是純組合語言的六倍。如果讀者仔細看看它產生出來的程式碼,就會覺得這個三倍的數字還是保守的估計。以下將說明之。

C2F 處理函式的參數,是在 return stack 上建立一個stack frame, 因此函式在進入/返回時有兩個基本的 overhead,第一個是 allocate stack frame,產生的 Forth code 是筆者常用來儲存暫時字串的方法,如: -nnnn RP@ + RP!,比較好一點點的作法應該是加入一個 RP+! 的詞。函式返回前自然必須釋放該區域 +nnnn RP@ + RP!。函式的呼叫及參數傳遞是和 Forth 相同,是從 data stack 來傳。

1. 函式輸入參數:

編譯結果的最前面是 [ 8 28 BIG_PROC ] 表示輸入參數共佔 28 bytes,因為它的float 佔12bytes,兩個float 加一個整數共 12+12+4 = 28 bytes。函式中間結果及區域變數佔 8 bytes,因此 8 28 BIG_PROC 會產生 -8 RP@ + RP!。其後的 >R F>R F>R 就是將輸入參數推到 return stack 上。所以,ff4() 的三個參數,xx位於 0 RP@ + 的位址,yy 位於 12 RP@ + 而 vv 位於 24 RP@ + 的位址。為了存取這些變數,必須以相對於 stack frame 頂部( RP@ ) 的距離來存取,這是由 &F 來作的,&F 會產生 “ RP@ +”,因此每一次取用/指定變數都必須多花一個加法。

2. 函式區域變數及計算過程的中間結果變數:

這部分是放在 stack frame 的最底部,它的存取是靠 &L 來達成,在這個範例中它會產生” 28 + RP@ + “,多花兩個加法,區域變數 zz 在 0 28 + RP@ + 的位置,而剩下的一個位置是放 while loop 的條件測試計算結果。

3. 整體變數與靜態區域變數:

在編譯初期就會情態的產生,用 $CREATE 及 ALLOT 來配置,因此每次存取得多花上一個類似eForth 的 CALL doVAR 的overhead。包括字串literal 在內,也是以此方式處理。

4. 函式輸出參數:

直接放在 TOS (Top of stack)上。不過 這套 C2F 還沒有處理把整個 struct 當作參數來傳入或傳出的部分。不過大部分的 library 或 API 為了效率的理由,很少這麼做,所以這個問題並不大。

二、指標:

指標在處理上並沒有什麼特殊之處,和一般變數沒什麼兩樣,只是變數存的內容是位址,對於struct 欄位的取用也只是直接加上位移量。至於 pointer of pointer 也是如此,所產生出來的code 也正確的反應出 @ 再 @ 的動作。

然而,對函式的位址 C2F 並沒有做處理,大大的限制了實用性。不過筆者已經修正了這個問題,只要修改 C2F 原始程式的 TREE.C 及 BYTECODE.C 就可以了。所有的函式位址參考都會加上[‘],函式指標的執行會加上 EXECUTE,於是解決了這個問題。

Forth 中的跳躍指令通常只有兩個,即 ?branch 及 branch,編譯器是用 IF … THEN 來產生 ?branch,而 branch 則是用 VJUMP 及 VLABEL 來產生,還記得前面所提的 DAG 語言嗎?它有標記(LABEL)指令,VLABEL 就是這個用途。VLABEL 和 VJUMP 處理了向前跳躍的問題,如果 LABEL 在跳躍之後才定義,則會將之前的向前參考串列處理掉,它是以直接產生 0xE9 xxxxxxxx 的方式來產生 branch (因為 SPF 是 macro-expansion subroutine threaded 的),在x86 上,branch = x86 的 JMP,而 ?branch = x86 的 JZ 指令。

此外,在處理 switch … case 結構時,LCC 已經將它做過最佳處理,可能會以跳躍表格來完成,產生出來的跳躍碼就不太好追蹤,不過至少在實測上它的 switch … case 是可以正常編譯並執行的。

在副程式呼叫方面,它的處理是如同一般的 Forth 程式的冒號定義一般。將參數按照C程式的參數順序列好,就可以直接呼叫。

四、程式碼的最佳化:

如一、中所敘述的,所有的變數引用都有相當大的overhead,然而之前也提到 SPF 在機械碼的處理上有最佳化,因此對所有的 “ literal RP@ + “ (&F) 或 “ literal literal + RP@ +” (&L),再加上其後的碼都是 @ 或 ! (F@ 或 F!)因此理應很容易將它最佳化成一兩個 386 指令,因為 386 的 addressing mode 在應付這些碼上是綽綽有餘的,如:

MOV EAX, [ESP+EDX+1234]

那麼執行上理應可以和原本的 Forth 碼在最佳化後速度差不多。只是不知道 SPF 是否有最佳化到如此程度。就算沒有,要再加進去應該也不是太大的問題。

在字串方面,如其他的 C compiler 一般,相同的字串literal 會被放在同一個位置,不會造成記憶體浪費。

五、產生Forth CPU碼及效率之考量:

之前最佳化的問題是在一個虛擬的Forth CPU上,但是同一個問題放在一個真的 Forth CPU 就不是這樣子了,除非前述三、中的 code sequence 可以被最佳化成一兩個特殊的Forth 指令,不然對程式效率會有很大的殺傷力。此外,return stack 的深度也是需要被考慮的因素之一,因為以這種實作方式,會耗用相當多的堆疊資源。

C2F編譯程式的限制:

這個程式目前仍有許多限制,有些在前面提過,這兒再總結一下:

1. 無法做 function prototype 宣告,然後向前參考,如:

因此要用 include 檔來建函式原型是行不通的。不過這方面倒可以在 SPF 中補強,只要加入 forward 參考的能力就可以了,這並不難,先定義一個詞兒的頭部,然後在後面真正冒號定義出現時再解決這個向前參考符號就好了,系統只要將分號「;」修改過便可。當一個字詞被定義完之後就去處理向前參考的符號。

另外,遞迴呼叫也有問題,不過這個解決方法很簡單,筆者修改了這個問題,由於SPF 並沒有「RECURSIVE」這個詞兒,它是用一個類似 F83 的機制「SMUDGE」, 每叫一次「SMUDGE」,系統就會將「LAST」的 SMUDGE BIT toggle 一次。

2. 無法處理資料結構當作參數的傳遞及傳出。

3. 無法處理不定引數函式的呼叫,如 int printf (char *format, … ) ,這得從 C 的呼叫慣例說起,C2F 為了處理C 的 stack frame 的問題,用 return stack 來模擬這個frame,然後由被呼叫者(callee)來釋放(C 的呼叫慣例是由呼叫者 caller 來釋放),這樣的順序就變成是 Pascal 的呼叫慣例,沒有辦法處理不定引數。這是程式最初設計上的架構問題,除非改進 stack frame 的處理方式,否則無解。這麼一來,最常用的 printf 函式就派不上用場了。

4. 對於沒有被用掉的函式傳回值,編譯程式沒有將其 DROP 掉,例如:

int forward(void);

int call_forward(void) { return forward(); }

int forward( void ) { return 1234; }

int return10(void) { return 10; }

void DataStackGarbage(void) { return10(); }

void DataStackBalance(void) { int x = return10(); }

實用的小技巧:

所以 data stack 若未加注意,會留下很多無用的值;若為遞迴呼叫,情況更糟。

5. C 還有一項很方便的功能,就是資料結構的bit field操作及計算,筆者常未加以測試。

6. 以下兩項是筆者修改的部分,本來無法處理 pointer of function,這可能是最大的不便,非常多的 C 程式都用到此功能,沒有這個功能無疑是自廢一半的武功。然而目前已經修正完成。可以正確的處理 pointer of function。如果要參考筆者所作的修改,只要在程式碼中找 “Luke” 就可以看到。

7. Conditional 判別的錯誤,在 C2F 的原始碼中,TREE.C 中包含了各種條件式的 DAG 語言對應,或者是作者太心急,把許多條件寫錯了,所以剛開始測試時,for loop, while loop 等完全不能運作,經過筆者修改現在已經可以正確執行了。

如同標準 C 程式一般,未宣告的函式內定都會被當作是外部參考(external reference),因此對所有「合乎 C 函式命名規則的 Forth 詞,都可以直接當作 C 函式來用,如:

KEY(); CR(); DROP(); DUP(); TYPE( “ABCDEFG”, 7 ); …

相當的方便。

總結:

由於筆者時間有限,測試涵蓋的範圍可能並不夠廣,一直測試就會一直發現新的問題,不過如果這個 C2F 作者有持續地維護程式的話,應該會進步地很快。不過C2F經過一些實測,筆者覺得至少目前已經堪用。至於許多上列的問題筆者還沒有時間好好修改,因此還請讀者們自行斟酌使用,或動手修改,只要手邊有一套 Borland C++ 3.1 就足以應付所有修改C2F 的工作。

不過 C 語言的方便性是在 library 的部分,至少要有 standard run-time library ( libc ) 才夠看。這方面還希望讀者們動動手,把一些好用的 C library 用 C2F 編譯並測試一下,集大家之力,就有實用化的希望。不然以目前的規模和編譯後程式碼的執行效率看來,只能算是實驗室的產品,還有很大的改進空間。