새로운 Smart Contract 프로그래밍 언어 만들기 — Compiler (3)

hihiboss
DE-labtory
Published in
12 min readMar 1, 2019

혹시 여기까지 오셨다면 정말 대단하십니다! 조금 더 힘을 내서 koa의 컴파일러를 정복해봅시다!

Compile a Contract!

지금까지 알아본 Bytecode를 이제 직접 compile 해봅시다.

Bytecode 구조

저번 시간에 살펴본 Bytecode 구조입니다. Memory Size부터 Exit까지 순서대로 compile하며 bytecode를 만들면 될 것 같지만 신경써야 할 것들이 있습니다.

  1. Memory Size를 컴파일 할 때, memory를 얼마나 사용하는지 아직 모른다.
  2. Function Jumper를 컴파일할 때, function selector를 통해 구할 함수의 id와 그 위치를 아직 모른다.
  3. 어떤 함수 안에서 다른 함수를 호출하면 Function Jumper로 jump해야 한다.

그렇기 때문에 Memory Size와 Function Jumper를 임의로 채워두고 각 함수를 컴파일 한 다음, Memory Size와 Function Jumper를 완성합니다.

func CompileContract(c ast.Contract) (Asm, error) {
bytecode := &Asm{
AsmCodes: make([]AsmCode, 0),
}

// Keep the size of the memory with createMemSizePlaceholder.
if err := createMemSizePlaceholder(bytecode); err != nil {
return *bytecode, err
}

// Keep the size of the jumper with createFuncJmprPlaceholder.
funcMap := FuncMap{}
if err := createFuncJmprPlaceholder(c, bytecode); err != nil {
return *bytecode, err
}

// Compile the functions in contract.
memTracer := NewMemEntryTable()
if err := compileFunctions(c.Functions, bytecode, funcMap, memTracer); err != nil {
return *bytecode, err
}

// Compile memory size with updated memory table.
// And replace expected memory size with new memory size of the memory table.
if err := compileMemSize(bytecode, memTracer); err != nil {
return *bytecode, err
}

// Compile function jumper with updated FuncMap.
// And replace expected function jumper with new function jumper
if err := compileFuncJmpr(c, bytecode, funcMap); err != nil {
return *bytecode, err
}

return *bytecode, nil
}

위 코드는 Contract를 컴파일하는 함수입니다. 먼저 createMemSizePlaceholdercreateFuncJmprPlaceholder 함수를 사용하여 bytecode에 공간을 미리 잡아둡니다. (FuncMap, MemEntryTable등은 뒤에서 설명해드리겠습니다.) 그리고 각 함수들을 컴파일한 뒤, compileMemSize, compileFuncJmpr로 MemSize와 Function Jumper를 컴파일합니다.

이렇게 하면 위에서 설명한 1, 2는 해결할 수 있습니다. 또, Function Jumper의 위치는 Memory SizeLoad Calldata 다음이기 때문에 가변적이지 않습니다. 그래서 placeholder로 bytecode를 채우고 함수들을 compile해도 Function Jumper로 jump할 수 있게 됩니다. Function Jumper의 위치는 언제나 Load Calldata 다음이니까요! 따라서 3도 해결할 수 있네요.

Memory Size

memory의 데이터를 사용하거나 사용한 memory의 크기를 알기 위해서는 memory가 어떻게 저장되어 있는지 알아야 합니다. 예를 들어 변수를 사용하는 코드를 컴파일할 때, 그 변수의 데이터는 memory의 어느 위치인지 알아야 합니다.

컴파일러가 memory 사용이 어떻게 되는지 알기 위해 Memory Table을 사용합니다. Memory TableMemTracer 인터페이스로 정의됩니다.

type MemTracer interface {
MemDefiner
MemGetter
}
type MemDefiner interface {
Define(id string) MemEntry
}
type MemGetter interface {
Entry(id string) (MemEntry, error)
Counter() int
MemSize() int
}

Defineid 에 해당하는 데이터를 어디에 저장할지 저장합니다. 데이터를 직접 저장하는 것이 아니라 메모리의 어느 곳에 얼마만큼 저장할 지에 대한 정보를 가지고 있는 것입니다. MemGetterMemory Table의 정보를 가져오기 위한 인터페이스입니다.

// MemEntry saves size and offset of the value which the variable has.
type MemEntry struct {
Offset int
Size int
}

// MemEntryTable is used to know the location of the memory
type MemEntryTable struct {
EntryMap map[string]MemEntry
MemoryCounter int
}

MemEntry는 메모리 정보를 담기 위한 구조체입니다! 메모리의 어디에(Offset), 얼마만큼 (Size) 저장하는지를 가지고 있습니다.

MemEntryTable은 실제 MemTracer의 구현체입니다! EntryMap에는 방금 살펴본 MemEntry가 담겨있고, MemoryCounter는 현재 얼마만큼의 메모리를 사용했는지를 나타냅니다.

자 그럼 이제 Memory Size를 compile 해볼까요?

func createMemSizePlaceholder(asm *Asm) error {
operand, err := encoding.EncodeOperand(0)
if err != nil {
return err
}
asm.Emerge(opcode.Push, operand)
asm.Emerge(opcode.Msize)

return nil
}
func compileMemSize(asm *Asm, tracer MemTracer) error {
operand, err := encoding.EncodeOperand(tracer.MemSize())
if err != nil {
return err
}

if err := asm.ReplaceOperandAt(1, operand); err != nil {
return err
}

return nil
}

Emerge함수는 파라미터에 대한 정보를 바이트코드로 바꾸는 함수입니다. Msize라는 opcode가 정의되어 있기 때문에 memory 크기를 Push해주고 Msize opcode를 컴파일 해주면 됩니다!

Load Calldata

koa에서는 프로그램을 실행하기 위해 함수 call을 해야한다고 앞에서 설명했었죠? 그 정보를 불러오는 것을 컴파일하는 것이 Load Calldata입니다!

asm.Emerge(opcode.LoadFunc)

LoadFunc opcode로 call하고자 하는 함수의 ID를 넣어주고 파라미터에 대한 정보도 넣어주면 끝입니다!

Function Jumper

Function Jumper는 실행하고자 하는 함수 위치를 찾아줍니다.

func compileFuncSel(asm *Asm, funcSel []byte, funcDst int) error {
// Duplicates the function selector to find.
asm.Emerge(opcode.DUP)
// Pushes the function selector of this function literal.
selector, err := encoding.EncodeOperand(funcSel)
if err != nil {
return err
}
asm.Emerge(opcode.Push, selector)
asm.Emerge(opcode.EQ)
asm.Emerge(opcode.NOT)
// If the result is false (the condition is true), jump to the destination of function.
dst, err := encoding.EncodeOperand(funcDst)
if err != nil {
return err
}
asm.Emerge(opcode.Push, dst)
asm.Emerge(opcode.Jumpi)

return nil
}

어떤 함수 ID에 대해 내가 찾고자 하는 함수 ID와 일치하는지 비교하고 일치하면 jump 하는 로직을 컴파일합니다. 먼저 찾고자 하는 함수 ID를 Dup opcode를 통해 복사합니다. 이는 EQ opcode로 비교하면서 ID가 pop되기 때문에 복사 후 비교하여 다음 ID에 대해서도 비교할 수 있도록 합니다.

그 다음, 비교하려는 함수 ID를 Push합니다. 그리고 EQNOT opcode로 ID가 서로 일치하는지 비교합니다. NOT이 사용되는 이유는 뒤의 Jumpiopcode때문입니다. 결과가 False일 때 jump하므로 NOT opcode를 통해 결과를 뒤집는 것이죠. 그럼 결과가 True일 때 jump하는 효과를 얻을 수 있습니다.

그리고 jump할 목적지(내가 실행하려는 함수의 위치)를 Push하고 Jumpi를 컴파일해주면 됩니다.

for _, f := range c.Functions {
selector := abi.Selector(f.Signature())
funcDst := funcMap[string(selector)]

if err := compileFuncSel(funcJmpr, selector, funcDst); err != nil {
return err
}
}

compileExit(funcJmpr)

이렇게 각 함수에 대해 compileFuncSel 해주면 function jumper가 완성됩니다! 함수의 위치는 각 함수들을 컴파일하면서 FuncMap에 저장해두고 Function Jumper를 컴파일할 때 FuncMap에서 꺼내줍니다.

그리고 마지막에 프로그램을 종료하는 Exit의 위치로 jump할 수 있도록 컴파일합니다. 그러면 call하고자 하는 함수가 contract 안에 존재하지 않을 때, 프로그램을 종료할 수 있게 됩니다.

Function A, Function B, …

이제 진짜 함수들을 컴파일합시다!

func compileFunction(f ast.FunctionLiteral, bytecode *Asm, tracer MemTracer) error {
for i, param := range f.Parameters {
if err := compileParameter(*param, i, bytecode, tracer); err != nil {
return err
}
}

statements := f.Body.Statements
for _, s := range statements {
if err := compileStatement(s, bytecode, tracer); err != nil {
return err
}
}

return nil
}

일단 함수를 실행하기 전, 파라미터들을 컴파일해줍니다. 그리고 Statement들을 컴파일합니다. koa의 함수는 Statement의 집합입니다. int a = 5, a = 3 + 5 등은 모두 Statement입니다. 그리고 StatementExpression으로 구성됩니다. int, a, 5, 3+5, 등이 모두 Expression입니다. 즉, 함수를 컴파일하는 것은 파라미터를 컴파일하고, Statement들을 컴파일하는 것이며 Statement를 컴파일하는 것은 Expression들을 컴파일 하는 것입니다.

그리고 함수 컴파일이 끝나면 되돌아가야 할 위치로 jump할 수 있게 컴파일합니다.

Exit

func compileExit(asm *Asm) {
asm.Emerge(opcode.Exit)
}

Exit은 Exit opcode를 컴파일하면 됩니다!

이상으로 koa의 compiler에 대해 알아보았습니다. bytecode를 만드는 것이 핵심입니다. 그리고 Stack, Memory에 대해서도 고려하면서 컴파일해야 하기 때문에 복잡한 구조로 되어 있습니다. 하지만 이러한 노력이 있어야 VM이 bytecode를 믿고 실행할 수 있겠죠?

--

--