從前面兩篇文章我們已經透過 mruby-L1VM 大致上了解了 mruby 生成的機器碼檔案包含了哪些資訊,不過還有幾個相對重要而且會經常被使用的方法需要提出來特別討論。
在 mruby-L1VM 的原始碼裡面,我們可以找到像這樣的片段
// mruby_l1vm.h
static inline int b2l2(const uint8_t* n) {
return (n[0] << 8) | n[1];
}
#ifdef SUPPORT_OVER64KB
int b2l4(const uint8_t* n) {
return (n[0] << 24) | (n[1] << 16) | (n[2] << 8) | n[3];
}
#else
#define b2l4(n) b2l2((n) + 2)
#endif
這是因為 mruby 在生成資料時,會呼叫 uint16_to_bin
這類方法,將一個整數拆分成 1 ~ 4 個 Byte 的陣列儲存,當我們要重新讀取原本儲存在裡面的整數數值時,就需要進行反向的操作。
舉例來說,在 mruby/dump.h
這個檔案,我們可以找到 uint16_to_bin
這個方法。
// mruby/dump.h
static inline size_t
uint16_to_bin(uint16_t s, uint8_t *bin)
{
*bin++ = (s >> 8) & 0xff;
*bin = s & 0xff;
return sizeof(uint16_t);
}
我們可以看到他對 bin
陣列填充了兩個數值,剛好就是 uint16_t
經過位移處理後的數值,我們只要做反向的處理就能夠還原回原本的數值。
目前還不清楚這樣處理的用意,也可能是因為是以
uint8_t
為單位寫入,直接放入超過大小的資料會造成遺失資訊所以才用這種方式進行處理。
相比 mruby-L1VM 的處理方式,在 mruby 原始碼中的作法個人認為更簡潔漂亮。
// mruby/opcode.h
#define PEEK_B(pc) (*(pc))
#define PEEK_S(pc) ((pc)[0]<<8|(pc)[1])
#define PEEK_W(pc) ((pc)[0]<<16|(pc)[1]<<8|(pc)[2])
會使用 B
、S
、W
是因為在 mruby 的 OPCode 定義裡面依據不同指令的參數定義,原始碼裡面大多會利用這些資訊來當作依據同時也讓原始碼更具可讀性。
除了前面提到的讀取資料的輔助方法之外,另外一個就是在 IREP 的資料區塊獲取我們所需資訊的輔助方法,ISEQ 的區塊我們會直接讀取來使用,但是 POOL 和 SYMS 會在我們呼叫方法或者要取得某個字串、數值時需要從裡面獲取,也因此會需要一個從中取得資料的方法。
這段程式碼看起來很長,不過實際上是三塊類似的邏輯所組成。
// mruby_l1vm.h
// literal: type, len(big endian short), data... (no null terminate)
// symbol : len(big endian short), data ... (with null terminate)
const uint8_t* irep_get(const uint8_t* p, int type, int n) {
int lenirep = b2l4(p);
//x_printf("irep record len: %d\n", lenirep); // ??
p += 8;
int nirep = b2l2(p);
p += 2;
{
int codelen = b2l4(p);
p += 4;
int align = (int)p & 3;
if (align) {
p += 4 - align;
}
p += codelen;
}
{
int plen = b2l4(p);
//x_printf("litlen: %d\n", plen);
if (type == IREP_TYPE_LITERAL) {
check(n >= 0 && n < plen);
plen = n;
}
p += 4;
for (int i = 0; i < plen; i++) {
uint8_t type = *p;
uint16_t len = b2l2(p + 1);
p += len + 3;
}
if (type == IREP_TYPE_LITERAL) {
return p;
}
}
{
int symlen = b2l4(p);
//x_printf("symlen: %d\n", symlen);
if (type == IREP_TYPE_SYMBOL) {
check(n >= 0 && n < symlen);
symlen = n;
}
p += 4;
for (int i = 0; i < symlen; i++) {
uint16_t len = b2l2(p);
p += len + 3; // len + '¥0'
}
if (type == IREP_TYPE_SYMBOL) {
return p;
}
}
{
//x_printf("n ireps: %d\n", nirep);
if (type == IREP_TYPE_IREP) {
check(n >= 0 && n < nirep);
nirep = n;
}
for (int i = 0; i < nirep; i++) {
//x_printf("ireps[%d]\n", i);
p = irep_get(p, IREP_TYPE_SKIP, 0);
}
if (type == IREP_TYPE_IREP) {
return p;
}
}
return p;
}
我們先簡化成比較簡單的虛擬碼來看,會是像這樣
const uint8_t* irep_get(const uint8_t* p, int type, int n) {
// 讀取 IREP 區段資訊
// 跳過 nlocals, nregs 等資料
// 讀取 ISEQ 長度
// 跳過 ISEQ 區塊
// 讀取 POOL 長度
// 如果讀取的是 LITERAL 資訊,將長度設定為 n
// 開始讀取 n 筆資料
// 如果讀取的是 LITERAL 資訊,回傳目前指標位置 p
// 重複 POOL 行為讀取 SYMS 和 IREP 區段
}
因為我們讀取的是 IREP 資料,因此原本放在開頭用來提供需要的區域變數、寄存器等資訊都可以直接跳過,接下來 ISEQ 是我們已經在執行的地方也可以跳過。
到了 POOL 區塊因為會儲存字串等資訊,假設我們要讀取的話則會讀取第 n
筆資料,而這個資料的資訊會被保存在 ISEQ 中,因此我們的迴圈會在執行到目標個數時回傳,假設不是要讀取這類資料的話就會將全部的資料都讀取一輪並跳過到下一個區段,重複這個行為直到取得希望抓取的資料為止。
至於每種區段則會因為儲存的資訊不同而有不一樣的讀取方式,像是 POOL 除了字串之外還會有其他類型的資訊,因此會提供一個 type
用於判斷,而 SYMS 因為都是字串的形式,就只會提供長度資訊用於判斷要移動指標多少才會到下一筆資料。
下一篇我們會開始準備專案新專案開始撰寫我們的 Ruby VM。
issue: typo, 第一個 coding blog 的檔名註解寫成 1lvm.h
了,應該是 l1vm.h
已修正