在這幾天的實作過程中,我們逐漸發現一個情況的出現,每當增加一個 OPCode 的處理就會有不少重複的程式碼需要輸入,而在 C 語言要對應這樣的狀況使用巨集就可以一定程度的消除這個狀況。
首先我們先來回顧一下 src/vm.c
目前的狀況。
// src/vm.c
//...
case OP_SEND:
a = *p++; b = *p++; c = *p++;
const char* func = (const char*)irep_get(data, IREP_TYPE_SYMBOL, b);
LOG("DEBUG> Call func: %s\n", func);
// TODO: Always call "puts"
for(int i = 1; i <= c; i ++) {
printf("%d\n", ((int)(reg[a + i])));
}
break;
//...
每一個 OPCode 的處理大多會伴隨著 a = *p++
這樣從 ISEQ 取出資料的狀況,同時也會有不少的重複以及在回去閱讀時造成的困難。
在 mruby 中巧妙的利用巨集將語法轉換為可以閱讀的形式,在 mruby 的 doc/opcode.md 裡面,我們會看到一個 Operand type
的欄位用有著不同的數值,在上一篇的修改中我們也用了相同的格式 B
或者 S
來表示處理不同單位的資料。
我們只需要幾個巨集就能幫助我們達到改善 mrb_exec
方法中每一個 OPCode 行為定義的處理,實作上來說也是相對簡單的。
// src/vm.h
#define CASE(insn,ops) case insn: FETCH_##ops ();;
#define NEXT break
在 mruby 中將 case OP_SEND:
的語法修改為 CASE(OP_SEND, BBB) { ... }
這樣的寫法當巨集被展開的時候就會自然的變成像下面這樣的原始碼
case OP_SEND: FETCH_BBB();; {
// ...
}
而 FETCH_
巨集則被定義在 src/opcode.h
沿用我們前面的 PEEK_
和 READ_
系列的巨集來做到讀取的效果。
// src/opcode.h
// ...
#define FETCH_Z() /* noop */
#define FETCH_B() do { a = READ_B(); } while(0)
#define FETCH_BB() do { a = READ_B(); b = READ_B(); } while(0)
#define FETCH_BBB() do { a = READ_B(); b = READ_B(); c = READ_B(); } while(0)
我們再次根據定義展開巨集就會得到這樣的原始碼
case OP_SEND: do { a = *p++; b = *p++; c = *p++ } while(0); {
// ...
}
扣除掉 do { ... } while(0)
的部分就跟我們原本撰寫的程式碼幾乎沒有差異了。
do { ... } while(0)
是 C 語言的一種技巧,可以確保被代入的程式碼不會因為其他程式碼而影響到,並且確實一起被執行。
我們將原本的處理重新整理後,就會得到類似下面的程式碼
switch(insn) {
CASE(OP_SEND, BBB) {
const char* func = (const char*)irep_get(data, IREP_TYPE_SYMBOL, b);
LOG("DEBUG> Call func: %s\n", func);
NEXT;
}
// ...
}
相較之前的版本比起來,我們更加明確的可以知道幾個關係
除了 CASE
的使用方式外,因為我們會對每一個 OPCode 做讀取資料的狀況,原本我們實作 OP_LOADI
系列的處理就會重複讀取了,因此 mruby 使用了 goto
的技巧來解決這個問題。
// ...
case OP_LOADI__1:
case OP_LOADI_0:
case OP_LOADI_1:
case OP_LOADI_2:
case OP_LOADI_3:
case OP_LOADI_4:
case OP_LOADI_5:
case OP_LOADI_6:
case OP_LOADI_7:
a = *src++;
reg[a] = op - OP_LOADI_0;
LOG("DEBUG> Load INT: %ld\n", reg[a]);
break;
// ...
改寫為下面的程式碼
CASE(OP_LOADI__1, B) goto L_LOADI;
CASE(OP_LOADI_0, B) goto L_LOADI;
CASE(OP_LOADI_1, B) goto L_LOADI;
CASE(OP_LOADI_2, B) goto L_LOADI;
CASE(OP_LOADI_3, B) goto L_LOADI;
CASE(OP_LOADI_4, B) goto L_LOADI;
CASE(OP_LOADI_5, B) goto L_LOADI;
CASE(OP_LOADI_6, B) goto L_LOADI;
CASE(OP_LOADI_7, B) {
L_LOADI:
reg[a] = insn - OP_LOADI_0;
DEBUG_LOG("r[%d] = mrb_int(%ld)", a, reg[a]);
NEXT;
}
因為我們可以利用 goto
跳到任何一個 Label 上面,因此在我們讀取完 a
的數值之後,直接跳到將數值儲存到 a
的位置,就可以避開從 ISEQ 抓取數值的動作,同時這樣的程式碼也變得相對的語意化,跟 Ruby 在語言設計上的一些理念也是互相呼應的。
除此之外我們原本用來顯示除錯的 LOG
的訊息也順便將他修改為 DEBUG_LOG
並且只在 DEBUG=1
的狀態下才會啟用。
// src/utils.c
#ifndef DEBUG
#define DEBUG_LOG(...)
#else
#define DEBUG_LOG(msg, ...) printf("[DEBUG] " msg "\n", ##__VA_ARGS__)
#endif
如此一來我們後續就可以直接用 DEBUG_LOG("xxx")
來顯示除錯訊息,而不需要手動加上 [DEBUG]
標籤以及 \n
換行符號。
調整為 DEBUG_LOG
後,我們也需要幫我們的 Makefile
增加一些設定讓我們可以用 DEBUG=1 make
和 make
來選則要不要呈現出除錯訊息。
// ...
CFLAGS =
ifdef DEBUG
CFLAGS += "-DDEBUG"
endif
all: run
%.o: %.c src/app.c
$(CC) $(CFLAGS) -I include -c $< -o $@
我們增加了一個 CFLAGS
變數會在將 .c
編譯成 .o
的時候加入,並且利用一個判斷式來判斷是否將 -DDEBUG
加入到選項裡面,讓 DEBUG=1
的效果被啟用。
到這邊我們對程式碼的清理大致上就告一段落,至少在後續擴充 OPCode 支援的時候能用相對乾淨的方式進行,下一篇我們要開始討論如何讓 Ruby 得以使用 C 語言定義的方法。