LL-Script 是一个简单的脚本语言,最初写的时候是为了给游戏做脚本,后来还是使用lua替代。很久后又重新开始按照规范写了一次,这次整体流程走通了,但是设计杂乱无章。前些日子从新写了一次,采用了一些良好的设计,这就是先在这个版本。

脚本源码在github上,访问这里。

概述

首先看整体流程:输入代码、输出opcode并执行

Lexer(source code) => token stream
Parser(token stream) => SSA (static single assginment form)
CodeGen(SSA) => opcode
VirtualMachine(opcode) => result

这里直接从源码构造中间代码,省去了语法树部分。另外Parser后应该为优化阶段,其输出为优化后的 SSA IR。

词法分析、语法分析

词法分析部分是简单的 ad-hoc 生成 token stream。Parser 按照 LL(1) 文法分析,并直接生成 SSA IR。

Script 只有最基本的作用域,没有块级作用域,整个函数中的变量在函数任意位置均可访问。FunctionScope 表示一个函数作用域,其原型如下:

struct FunctionScope {
    typedef std::unordered_map<std::string, unsigned> Symbols;

    // symbol type
    enum { None, Define, Let };

    FunctionScope()
        : cfg_(nullptr)
        , block_(nullptr)
    {}

    CFG *cfg_;
    Symbols symbolTable_;
    BasicBlock *block_;
    std::unordered_set<std::string> captures_;
};

cfg 表示函数对应 SSA 的控制流程图,主要用于提供源代码直接到IR的转换,block_ 表示当前翻译过程中源代码对应IR所在的基本块。Symbols 使用 unordered_map 来表示符号容器,其中first表示符号名,second表示符号类别,由enum { None, Define, Let };决定。symbolTable_ 就是主要的符号表,函数作用域中定义的符号均记录在此。captures_表示函数的捕获(c++名词)列表,记录在此,给翻译收尾工作提供必要的信息。

在翻译过程中,主要用到查找符号原型如下:

bool tryToCatchID(std::string &name);
bool tryToCatchID(scope_iterator iter, std::string &name);

首先通过原型1在当前作用域中查找,如果找到返回true,如果没有,则从里向外递归地遍历函数作用域栈。找到后,如果为外部符号,则加入捕获列表,否则返回false。

SSA 形式中间代码生成

从源代码直接生成 SSA 形式代码可以参考论文:

Simple and Efficient Construction of Static Single Assignment Form Matthias Braun1, Sebastian Buchwald1, Sebastian Hack2, Roland Leißa2, Christoph Mallon2, and Andreas Zwinkau1

Script 中涉及到的代码主要在 Parser 分析过程中以及 CFG 中,CFG 部分内容如下:

class CFG
{
public:
    // ....
    // SSA form construction.
    void sealOthersBlock();
    void sealBlock(BasicBlock *block);
    void saveVariableDef(std::string name, BasicBlock *block, Value *value);
    Value *readVariableDef(std::string name, BasicBlock *block);
protected:
    // SSA
    Value *readVariableRecurisive(std::string name, BasicBlock *block);
    Value *addPhiOperands(std::string name, Phi *phi);
    Value *tryRemoveTrivialPhi(const std::string &name, Phi *phi);
    // ....
};

这部分代码详细使用请看论文或博客。

SSA IR

SSA IR 的 use-def chain 主要参考 LLVM 2.0 源码。SSA IR 中基础数据结构有 Use、Value、User,每一条 Instruction 既是 Value 又是 User,User 和 Value 通过 Use 关联起来。一些列指令组成 BasicBlock,一些列 BasicBlock 组成 CFG。IRContext包装了Instruction创建的工作。每一个函数由一个 CFG 组成,所有函数一起组成 IRModule。也就是说 Parser 输出为 IRModule。

函数处理

生成 SSA IR 部分内容并不复杂,除了有关函数、Lambda部分。Script中无论函数定义可以看作如下替换(步骤1):

function bar() {}
=>
function $lambda_1_bar() {}
define bar = $lambda_1_bar;

因为闭包对于普通变量通过拷贝、对于复合变量通过引用(实际实现为指针)实现。所以可以通过类似于C++std::bind的机制实现 closure(步骤2)。

let a = 10;
define call = lambda(x) {
    return x + a;
}
=>
let call_tmp = lambda(a, x) ....
 define call = call_tmp(a);

不过这样处理就会涉及到函数中访问其自己名字的问题,或者说叫访问lambda函数自己。这个问题可以通过 Y combinator 解决。Script 中使用的办法比较简单(步骤3):

let fib = lambda(n) {
    if (n < 2)	return 0;
    return fib(n-1) + fib(n-2);
}
=>
let tmp = lambda(fib, n) {
    fib = fib(fib);
    if (n < 2) return 0;
    return fib(n-1) + fib(n-2);
}
let fib = tmp(fib);

有了上述基础,就可以看相关代码:

Value *Parser::parseFunctionCommon(const std::string &name)
{
    // create function and generate parallel invoke.
    // for module require, need insert file name
    IRFunction *function = module_.createFunction(
        Combinator(lexer_.filename(), name));
    pushFunctionScopeAndInit(function);

    Strings params;
    getFunctionParamsAndBody(params, function);

    // save current captures.
    std::vector<std::string> prototype;
    getFunctionPrototype(name, prototype, params);
    function->setParams(std::move(prototype));

    std::unordered_set<std::string> captures;
    std::swap(captures, scope->captures_);
    if (captures.find(name) != captures.end())
        dealRecursiveDecl(name);
    popFunctionScope(function);

    // create closure for function.
    return createClosureForFunction(name, captures);
}

parseFunctionCommon 首先分析函数参数和函数体,然后获取函数原型(步骤2),根据函数是否递归调用自身,调用dealRecursiveDecl(步骤3),最后为函数创建闭包(步骤1)。

在函数原型部分,Script 将自身的名字放到 capture 列表的最后部分位置,这样使得处理递归调用变得简单。

目标代码生成

代码生成流程如下:

void CodeGen::runOnFunction(IRFunction *func)
{
    std::list<LiveInterval> intervals;
    {
        LiveIntervalAnalysis analysis;
        analysis.runOnFunction(func);
        analysis.swapIntervals(intervals);
    }

    SimpleRegisterAllocation RA(255, intervals);
    RA.runOnFunction(func);
    PhiElimination PE;
    PE.runOnFunction(func);

    numOfRegister = RA.totalRegister();
    genFunction(func);
}

首先是分析变量活性区间,然后通过活性区间进行寄存器分配,最后消除 Phi 结点,此时 IR 退出 SSA 形式。然后在这个基础上以函数为单位进行代码生成。

CodeGen 目标为opcode,而opcode相关操作包装在OPBuilder中。具体逻辑比较简单,请直接看源码。

活性区间计算与寄存器分配

活性区间及寄存器分配以:

Linear Scan Register Allocation for the Java HotSpot™ Client Compiler - Christian Wimmer

论文为基础,详细逻辑可以参考该论文。

活性区间计算涉及到函数如下:

void buildIntervals(IRFunction *func);
void computeLocalLiveSet(IRFunction *func);
void computeGlobalLiveSet(IRFunction *func);

按照论文,首先计算 BasicBlock 顺序并编号,然后依次计算本地活性集和全局活性集,最后才建立活性区间。

void LiveIntervalAnalysis::runOnFunction(IRFunction *func)
{
    func->computeBlockOrder();
    computeLocalLiveSet(func);
    computeGlobalLiveSet(func);
    buildIntervals(func);
}

寄存器分配部分,原先的想法是使用线性扫描寄存器分配方法,在实现过程中,部分细节不知道怎么处理,所以就使用了比较简单的分配方式,将所有变量放到栈上,所有临时变量才分配寄存器,由 VM 约定最多255个寄存器。在 SimpleRegisterAllocation 中具体操作如下:

void initAndSortIntervals();
void assignRegister(IRFunction *func);
void allocateNewRegister(LiveInterval &interval);
void expiredOldIntervals(unsigned current, ActiveSet &active, ActiveSet &inactive);
bool tryToAllocateRegister(LiveInterval &interval, ActiveSet &active, ActiveSet &inactive);

具体实现过程,参考博客;

Runtime

虚拟机的数据对象采用 Tagging 标记方式,整个 Runtime 以 C 语言为主实现,封装后供 C++ 使用。对象基本结构如下:

enum Tag {
    TagNot = 0,
    TagFixnum = 1,
    TagReal = 2,
    TagSpec = 3,

    TagNil = 7,

    TagShift = 2,
    TagMask = 3,

    TagSpecalMask = 0xf,
    TagSpecalShift = 4,
};

// 8bits
enum Type {
    TypeString = 0,
    TypeArray = TypeString + 1,
    TypeClosure = TypeString + 2,
    TypeUserFunc = TypeString + 3,
    TypeHashNode = TypeString + 4,
    TypeUserData = TypeString + 5,
    TypeHashTable = TypeString + 6,
};

// common property of heap object
// obType_ is the type of object
#define HEAP_OBJECT_HEAD   \
    int8_t obType;         \
    int8_t resv1;          \
    int8_t resv2;          \
    int8_t resv3

typedef struct {
    HEAP_OBJECT_HEAD;
} CommonObject;

Tag 用于标记简单对象类型,复合对象继承自 CommonObject,使用 obType 表示具体对象类型即 Type 中对应的类型。

以String为例 :

typedef struct
{
    HEAP_OBJECT_HEAD;
    size_t length;
    char str[];
} String;

任何一个 String 对象长度为 sizeof(String) + String.length。所有数据结构中,最为复杂的是 Hash 表的设计。

typedef struct HashNodeList
{
    HEAP_OBJECT_HEAD;
    size_t capacity;
    HashNode content[0];
} HashNodeList;

typedef struct Hash
{
    HEAP_OBJECT_HEAD;
    size_t capacity;
    size_t size;
    size_t max_idx;
    HashNodeList *content;
} Hash;

Hash 是 Hash 表的数据结构,其中数据存放在 HashNodeList 部分,这样设计是用于后面的 Expand 以及 Shrink 操作。这里说明 hash 表 set 操作:


static void HashSet(Object self, uintptr_t key, Object value)
{
    assert(IsFixnum(value));

    Hash *hash = (Hash*)self;
    uintptr_t index = key % hash->capacity;
    uintptr_t slot = hash->capacity;

    if (IsNil(value)) {
        value = CreateUndef();
    }

    HashNodeList *list = hash->content;
    while (list->content[index].key != key
        && !IsNil(list->content[index].value)) {
        if (slot == hash->capacity
            && IsUndef(list->content[index].value)) {
            slot = index;
            break;
        }
        index = HashNextIndex(index, hash->capacity);
    }

    if (list->content[index].key == key)
        slot = index;
    if (slot == hash->capacity)
        slot = index;

    if (!IsUndef(value) && (IsNil(list->content[slot].value)
        || IsUndef(list->content[slot].value))) {
        assert(hash->size != hash->capacity);
        if (key == HashKey(CreateFixnum(hash->max_idx)))
            hash->max_idx++;
        hash->size++;
    }

    if (IsUndef(value) && !IsUndef(list->content[slot].value)
        && !IsNil(list->content[slot].value)) {
        assert(hash->size);
        if (key == HashKey(CreateFixnum(hash->max_idx - 1)))
            hash->max_idx--;
        hash->size--;
    }

    list->content[slot].value = value;
    list->content[slot].key = key;
}

首先计算hash对象所在 HashNodeList 中的 slot,如果当前位置已经被占用,则计算下一个 slot 所在位置。找到位置后,根据是否 nil 判断是否删除结点。

Object HashFind(Object self, Object key)
{
    assert(IsHash(self));
    Hash *hash = (Hash*)self; 
    uint32_t hash_key = HashKey(key);
    uintptr_t index = hash_key % hash->capacity;

    HashNodeList *list = hash->content;
    while (list->content[index].key != hash_key
        && !IsNil(list->content[index].value)) {
        if (IsUndef(list->content[index].value))
            break;
        index = HashNextIndex(index, list->capacity);
    }

    if (IsNil(list->content[index].value))
        return CreateUndef();
    else
        return list->content[index].value;
}

find 操作查找部分与 set 类似,最后直接返回对象值。实际上对外暴露的接口是:

void HashSetAndUpdate(Object self, Object key, Object value)
{
    assert(IsHash(self));

    if (key == CreateFixnum(-1))
        key = CreateFixnum(((Hash*)self)->max_idx);
    uint32_t hash_key = HashKey(key);
    HashSet(self, hash_key, value);
    if (HashNeedExpand(self)) 
        HashExpand(self);
    else if (HashNeedShrink(self)) 
        HashShrink(self);
}

这里对 set 后的表进行判断,并根据结果伸展或者收缩表。伸展表时,目标容积为原容积的 3/2,收缩时,目标容积为原容积的 2/3。

需要注意的是 Runtime 中申请内存也必须在 Runtime 中,保证 GC 能够访问到该内存。所以 Runtime 设计中出现的内存分配必须使用 GC 提供的接口。另外,如果分配过程中发生了 GC,也需要保证所有指针均指向正确的位置。

// 
// just expand content.
static void HashExpand(Object self)
{
    assert(IsHash(self));
    Hash *hash = (Hash*)self;
    size_t future_capacity = HashExpandSize(hash->capacity);

    // before gc, save it as global object
    GlobalObjectBuffer = &self;
    HashNodeList *cap = (HashNodeList*)HashNewNodeList(future_capacity);
    GlobalObjectBuffer = NULL;

    HashRehash(self, cap);
}

因此,在 Runtime 中提供了一个特殊的对象 GlobalObjectBuffer来保证内存指针始终指向正确的位置。

Virtual Machine & GC

typedef int8_t Byte;

enum Opcode {
    OK_Goto = 0,    // goto lable@_addr

    // operator
    // single 
    OK_Not,         // temp = !temp
    // binary 
    OK_Add,         // temp = temp + temp
    OK_Sub,         // temp = temp - temp
    OK_Mul,         // temp = temp * temp
    OK_Div,         // temp = temp / temp
    // relop
    OK_Great,       // temp = temp > temp
    OK_GreatThan,   // temp = temp >= temp
    OK_Less,        // temp = temp < temp
    OK_LessThan,    // temp = temp <= temp
    OK_Equal,       // temp = temp == temp
    OK_NotEqual,    // temp = temp != temp

    // move
    OK_MoveS,       // temp = string index
    OK_MoveI,       // temp = constant
    OK_MoveF,		// temp = float
    OK_MoveN,		// temp = null
    OK_Move,        // temp = temp

    // memory
    OK_Load,        // load id to temp
    OK_Index,       // load id [ temp ] to temp
    OK_Store,       // store id from temp
    OK_SetIndex,      // store id [ temp ] from temp

    // condition jmp
    OK_If,          // if temp goto label

    // call 
    OK_Param,       // push temp
    OK_Call,        // temp = call Label in num params
    OK_TailCall,
    OK_Return,      // return temp
    OK_NewHash,		// tmp = new hash
    OK_NewClosure,	// tmp = new string(idx)
    OK_UserClosure, // tmp = new user closure
    OK_Halt,        // stop
};

opcode 设计如上,VM 对其加以解释执行。

VM 中有三个重要的数据结构,VMFrame、VMScene、VMState,VMFrame 是运行时的一个调用栈帧,保存了当前所在位置的运行时数据。VMScene是一个运行场景,每一个程序一个运行场景,当前场景运行不会影响到后续场景的运行。VMState是opcode解释器,绑定VMScene运行。在同一时刻,只能有一个VMState运行,因为需要涉及到Runtime中的设计。当然这中设计导致了这种缺陷,但是目前是最简单的实现方式。

GC 采用了semi-space swap算法,

struct Semispace
{
    Object allocateMemory(size_t size);
    bool contains(Object obj);
    void reset();
};

class GarbageCollector
{
public:
    Object allocate(size_t size);

    void bindReference(std::function<VariableReference> call);
    void bindGlobals(std::function<GloablVariable> call);
    void processReference(Object *slot);

private:
    void garbageCollect();

    void swapSpace();
    void cleanSpace(Semispace *space);

    bool isForwarded(Object obj);
    void forwardTo(Object obj, Object new_addr);
    Object forwardee(Object obj);

    Object swap(Object obj, size_t size);

    Semispace *from_space_;
    Semispace *to_space_;
};

主要数据结构如上。其中有两个回掉函数,其中GloablVariable用于与VM沟通,获取根结点数据;VariableReference用于与Runtime沟通,处理变量引用,这样将GC与VM和Runtime解耦,以后修改GC更为方便。GC详细介绍可以看文章。

在 buildin.cpp 中提供了 Runtime 涉及到的操作和 GC 耦合部分内容:

void ProcessGlobals(void *scene)
{
    using script::VMFrame;
    using script::VMScene;
    using script::GarbageCollector;

    VMScene *vmscene = static_cast<VMScene*>(scene);
    GarbageCollector *GC = &vmscene->GC;
    for (auto &frame : vmscene->frames) {
        GC->processReference(&frame.params);
        GC->processReference(&frame.registers);
    }
    for (auto &object : vmscene->paramsStack) {
        GC->processReference(&object);
    }
    if (GlobalObjectBuffer != NULL)
        GC->processReference(GlobalObjectBuffer);
}

void ProcessVariableReference(void *scene, Object *object)
{
    using script::VMScene;
    using script::GarbageCollector;

    VMScene *vmscene = static_cast<VMScene*>(scene);
    GarbageCollector *GC = &vmscene->GC;
    if (IsClosure(*object)) {
        size_t hold = ClosureHold(*object);
        Object *params = ClosureParams(*object);
        for (size_t idx = 0; idx < hold; ++idx) {
            GC->processReference(&(params[idx]));
        }
    }
    else if (IsArray(*object)) {
        size_t length = ArraySize(*object);
        Object *array = ArrayPointer(*object);
        for (size_t idx = 0; idx < length; ++idx) {
            GC->processReference(&array[idx]);
        }
    }
    else if (IsHash(*object)) {
        GC->processReference(HashNodeListGet(*object));
    }
    else if (IsHashNodeList(*object)) {
        size_t size = NodeListElementCapacity(*object);
        HashNode *nodes = HashNodeListElement(*object);
        for (size_t idx = 0; idx < size; ++idx) {
            GC->processReference(&nodes[idx].value);
        }
    }
}

上面代码就是用于处理 GC 的回掉工作。

在 VM 当中提供了 tailCall 指令用于尾调用(并不局限与尾递归)优化,对于那些在返回语句中调用了其他函数的调用语句均可以优化。具体VM中则是重复利用当前 VMFrame ,达到尾调用的目的。

仍然需要注意的是使用 GC 出现的具有迷惑性的问题,如果某条指令执行过程中,创建了对象,就要保证创建前后指针值的位置正确。

FFI 支持

在Runtime和VM中提供了FFI支持:

///
/// user func object
///
typedef struct
{
    HEAP_OBJECT_HEAD;
    void *content;
} UserClosure;

该结构用于包装用户函数,然后 VM 中可以使用 callUserClosure 与用户定义函数进行交互。

这些用户定义函数需要在编译器中先进行注册后才能被编译器识别:

Object lib_require(VMState *state, size_t paramsNums)
{
    assert(globalReguireCallback);
    if (paramsNums != 1) {
        state->runtimeError("require only takes one parameter");
    }

    Object res = state->getScene()->paramsStack.back();
    if (IsString(res)) {
        // save it.
        std::string filename = StringGet(res); 
        VMScene *scene = state->getScene();
        unsigned resReg = static_cast<unsigned>(scene->lastValue);
        globalReguireCallback(filename.c_str(), resReg);
    }
    return CreateUndef();
}

static Lib libs[] = {
    { "require", lib_require },
    { nullptr, nullptr }
};

void RegisterLibrary(LibRegister lib_register)
{
    Lib *lib = libs;
    while (lib->name) {
        lib_register(lib->name, lib->closure);
        lib++;
    }
}

void RegisterRequire(RequireCallback require)
{
    globalReguireCallback = require;
}

这里是对脚本提供了模块加载功能,首先用用户提供自定义函数(原型与lib_require一致),然后保存在libs[]中,编译器初始化时,会将具体的注册函数传递给RegisterLibrary并一一注册。

对于一个C函数的定义,在注册过程中需要向Parser和opcode中注册,方便符号查找。