[루아1.1] opcode.c 읽기 (2)

11 분 소요

Opcode.c (2)

이전 글에 이어서 Opcode.c 파일을 계속 읽겠습니다. 오늘은 lua_execute() 함수 하나만 읽고 글을 마무리 할 생각입니다. 함수가 커요. 아마 lua_execute() 함수가 루아 VM 구현 자체인것으로 보입니다.

int lua_execute (Byte *pc)
{
 Object *oldbase = base;
 base = top;
 while (1)
 {
  OpCode opcode;
  switch (opcode = (OpCode)*pc++)

시작 코드입니다. pc 배열은 Lua.stx에서 yacc 문법을 파싱하면서 만든 바이트 스택입니다. 명령어와 데이터가 코드 문법 파싱 순서와 규칙에 따라 쭉 들어가 있지요. 이것을 하나씩 읽으면서 동작을 처리하고 그 결과를 Object 스택에 넣는 방식으로 루아 VM이 동작하는가 봅니다.

   case PUSHNIL: tag(top++) = T_NIL; break;
   
   case PUSH0: tag(top) = T_NUMBER; nvalue(top++) = 0; break;
   case PUSH1: tag(top) = T_NUMBER; nvalue(top++) = 1; break;
   case PUSH2: tag(top) = T_NUMBER; nvalue(top++) = 2; break;

   case PUSHBYTE: tag(top) = T_NUMBER; nvalue(top++) = *pc++; break;
   
   case PUSHWORD: 
   {
    CodeWord code;
    get_word(code,pc);
    tag(top) = T_NUMBER; nvalue(top++) = code.w;
   }
   break;
   
   case PUSHFLOAT:
   {
    CodeFloat code;
    get_float(code,pc);
    tag(top) = T_NUMBER; nvalue(top++) = code.f;
   }
   break;

   case PUSHSTRING:
   {
    CodeWord code;
    get_word(code,pc);
    tag(top) = T_STRING; svalue(top++) = lua_constant[code.w];
   }
   break;

PUSH 어쩌구 시리즈 명령어 처리 부분입니다.

PUSHNIL, PUSH0, PUSH1, PUSH2는 명령어 자체에 값을 스택에 넣으라고 지시하고 있습니다. 그래서 pc를 변경하지 않고 바로 스택에 nil, 0, 1, 2를 넣습니다.

PUSHBYTE는 명령어를 처리하는데 2 바이트가 필요합니다. 명령어 자체와 스택에 넣을 값, 이렇게 2 바이트가 필요하죠. 명령어 자체는 루프 돌면서 switch 문에서 처리했고 case 문에서는 데이터를 바이트 스택에서 빼야 합니다. 그래서 nvalue(top++) = *pc++로 pc를 하나 증가하는 코드를 코딩한 것입니다.

PUSHWORD 명령어는 opcode 자체를 제외하고 2 바이트가 더 필요합니다. 그러므로 pc를 두 번 증가시켜야 하지요.

#define get_word(code,pc)    {code.m.c1 = *pc++; code.m.c2 = *pc++;}

get_word() 매크로 구현을 보면 pc가 두 번 증가합니다.

PUSHFLOAT는 4바이트이므로 opcode 자체를 제외하고 4바이트가 더 필요합니다. 그러므로 pc를 네 번 증가시켜야 합니다.

#define get_float(code,pc)   {code.m.c1 = *pc++; code.m.c2 = *pc++;\
                              code.m.c3 = *pc++; code.m.c4 = *pc++;}

case 구문에서 호출하는 get_float() 매크로를 보면 pc가 네 번 증가하는 코드가 있습니다.

루아 문법을 파싱할 때 문자열은 문자열 테이블에 저장하고 바이트 스택엔 문자열 테이블 인덱스를 저장합니다. Lex.c 파일에서 STRING 토큰을 처리하는 코드를 다시 보면 알 수 있습니다. 그러므로 바이트 스택에서 2바이트 word 크기 데이터를 읽어서 그 값을 인덱스로 lua_constant 배열에서 문자열 포인터를 받아 옵니다. 이 포인터 값이 Object 스택에 루아 string 타입으로 저장됩니다.

   case PUSHLOCAL0: case PUSHLOCAL1: case PUSHLOCAL2:
   case PUSHLOCAL3: case PUSHLOCAL4: case PUSHLOCAL5:
   case PUSHLOCAL6: case PUSHLOCAL7: case PUSHLOCAL8:
   case PUSHLOCAL9: *top++ = *(base + (int)(opcode-PUSHLOCAL0)); break;
   
   case PUSHLOCAL: *top++ = *(base + (*pc++)); break;

PUSHLOCAL 계열 명령어를 처리하는 코드입니다. 뭐 하는 명령어일까요? 스택 베이스 포인터 위치를 기준으로 N 칸 (단위가 Object 자료형 크기라서 ‘칸’이라는 표현이 제일 적당해 보입니다.) 위에 있는 값을 읽어서 스택에 넣는 동작을 합니다. 이 동작과 PUSHLOCAL이라는 이름이 머릿속에서 잘 어울리지 않네요. 일단은 그냥 그러려니하고 넘어 가겠습니다.

   case PUSHGLOBAL: 
   {
    CodeWord code;
    get_word(code,pc);
    *top++ = s_object(code.w);
   }
   break;

그 다음에는 PUSHGLOBAL입니다. 이름이 PUSHLOCAL과 반대네요. 코드는 딱 세 줄인데 의미를 파악하려면 코드를 타고 가봐야 겠네요. get_word() 매크로는 앞에서 읽었던 매크로입니다. s_object() 매크로 구현을 보죠.

#define s_object(i)	(lua_table[i].object)

get_word() 매크로로 바이트 코드 스택에서 2바이트 값을 읽습니다. 이 값은 lua_table 배열의 인덱스로 들어가서 lua_table의 object 맴버 값을 읽습니다. Object 객체의 포인터 값이겠죠.

종합해보면 PUSHLOCAL은 스택에 있는 값을 읽어서 다시 스택에 넣는 것이고 PUSHGLOBAL은 심볼 테이블에 있는 심볼값을 읽어서 스택에 넣는 명령입니다. 이렇게 보니 좀 이름과 동작이 어울리는 것 같습니다.

   case PUSHINDEXED:
    --top;
    if (tag(top-1) != T_ARRAY)
    {
     lua_reportbug ("indexed expression not a table");
     return 1;
    }
    {
     Object *h = lua_hashdefine (avalue(top-1), top);
     if (h == NULL) return 1;
     *(top-1) = *h;
    }
   break;
   
   case PUSHMARK: tag(top++) = T_MARK; break;
   
   case PUSHOBJECT: *top = *(top-3); top++; break;

PUSHINDEXED 명령어는 동작 추적이 쉽지 않습니다. 사용되는 곳은 lua_pushvar() 함수 한 곳입니다. 이 함수는 루아 문법에서 변수 관련 문법 여러 곳에 등장합니다. 지난 글에서 읽고 지나갔긴 하지만 다시 한 번 보겠습니다.

static void lua_pushvar (long number)
{ 
 if (number > 0)	/* global var */
 {
  code_byte(PUSHGLOBAL);
  code_word(number-1);
  incr_ntemp();
 }
 else if (number < 0)	/* local var */
 {
  number = (-number) - 1;
  if (number < 10) code_byte(PUSHLOCAL0 + number);
  else
  {
   code_byte(PUSHLOCAL);
   code_byte(number);
  }
  incr_ntemp();
 }
 else
 {
  code_byte(PUSHINDEXED);
  ntemp--;
 }
}

이해가 안되는 부분이 lua_pushvar 함수에서 if-else 구문입니다. number가 0일 때만 PUSHINDEXED 명령어를 바이트 코드 스택에 넣거든요. 그런데 문법 코드만 봐서는 number가 0이 되는 경우가 어떤 상황인지 알 수가 없습니다. 일단 이 정도만 정리해 두고 넘어 가겠습니다. 다음 릴리즈 코드 읽을 때 쯤엔 알게 될 수도 있겠지요. 아니면 20년 가까이 20번 넘게 릴리즈하면서 해당 기능 구현이 바뀔 수도 있으니 지금 굳이 애써서 100% 이해할 필요는 없습니다. 우선 읽는 것이 중요하니까요.

   case STORELOCAL0: case STORELOCAL1: case STORELOCAL2:
   case STORELOCAL3: case STORELOCAL4: case STORELOCAL5:
   case STORELOCAL6: case STORELOCAL7: case STORELOCAL8:
   case STORELOCAL9: *(base + (int)(opcode-STORELOCAL0)) = *(--top); break;
    
   case STORELOCAL: *(base + (*pc++)) = *(--top); break;
   
   case STOREGLOBAL:
   {
    CodeWord code;
    get_word(code,pc);
    s_object(code.w) = *(--top);
   }
   break;

STORELOCAL 시리즈 명령어와 STOREGLOBAL 명령어는 PUSHLOCAL/PUSHGLOBAL 명령어와 동작이 반대입니다. 쉽습니다.

   case STOREINDEXED0:
    if (tag(top-3) != T_ARRAY)
    {
     lua_reportbug ("indexed expression not a table");
     return 1;
    }
    {
     Object *h = lua_hashdefine (avalue(top-3), top-2);
     if (h == NULL) return 1;
     *h = *(top-1);
    }
    top -= 3;
   break;
   
   case STOREINDEXED:
   {
    int n = *pc++;
    if (tag(top-3-n) != T_ARRAY)
    {
     lua_reportbug ("indexed expression not a table");
     return 1;
    }
    {
     Object *h = lua_hashdefine (avalue(top-3-n), top-2-n);
     if (h == NULL) return 1;
     *h = *(top-1);
    }
    top--;
   }
   break;

위 코드를 보니 INDEXED라고 붙은 명령어들은 루아 테이블 타입 변수를 다루는 명령어 인것 같습니다. 루아 테이블 타입 변수의 인덱스를 받아서 값을 읽거나 쓰는 동작이라는 것은 알겠는데 구체적인 동작은 파악이 안되네요. 그냥 여기까지만 알아두고 넘어가겠습니다.

   case ADJUST:
   {
    Object *newtop = base + *(pc++);
    while (top < newtop) tag(top++) = T_NIL;
    top = newtop;  /* top could be bigger than newtop */
   }
   break;

앞에서 코드 읽을 때 자주 나와서 동작이 몹시 궁금했던 ADJUST 명령어입니다. 코드만 보면 스택 top을 조정하는 명령어군요. 그런데 코드 내용을 보면 newtop을 base에서 N만큼 더한 위치로 잡습니다. base는 스택 바닥이므로 top을 newtop으로 바꾼다는 말은 스택 꼭대기를 아래로 내린다는 뜻인데.. 이러면 스택에 있는 값을 버린다는 건가요.. 아니면 제가 반대로 이해 한 것일 수도 있으니 일단은 이정도로 읽고 가겠습니다. 루아 구현이 스택을 어떤 방식으로 사용하는지 파악하기 전까지는 흐름 정도만 알 수 있을 것 같습니다.

   case CREATEARRAY:
    if (tag(top-1) == T_NIL) 
     nvalue(top-1) = 101;
    else 
    {
     if (tonumber(top-1)) return 1;
     if (nvalue(top-1) <= 0) nvalue(top-1) = 101;
    }
    avalue(top-1) = lua_createarray(nvalue(top-1));
    if (avalue(top-1) == NULL)
     return 1;
    tag(top-1) = T_ARRAY;
   break;

명령어 이름이 CREATEARRAY이니, 배열을 만드는 명령어라는걸 미루어 짐작할 수 있습니다. 스택에서는 배열 크기를 받아 오는 듯 합니다. 배열 크기를 받아야 하는데 그 값이 nil이면 배열을 만들 수 없는데 101을 넣습니다. 101을 넣은 채로 lua_createarray() 함수를 호출하는 것 보니 101은 에러 코드 같은 것이 아니라 기본값으로 지정하는 배열 크기인듯 합니다.

   case EQOP:
   {
    Object *l = top-2;
    Object *r = top-1;
    --top;
    if (tag(l) != tag(r)) 
     tag(top-1) = T_NIL;
    else
    {
     switch (tag(l))
     {
      case T_NIL:       tag(top-1) = T_NUMBER; break;
      case T_NUMBER:    tag(top-1) = (nvalue(l) == nvalue(r)) ? T_NUMBER : T_NIL; break;
      case T_ARRAY:     tag(top-1) = (avalue(l) == avalue(r)) ? T_NUMBER : T_NIL; break;
      case T_FUNCTION:  tag(top-1) = (bvalue(l) == bvalue(r)) ? T_NUMBER : T_NIL; break;
      case T_CFUNCTION: tag(top-1) = (fvalue(l) == fvalue(r)) ? T_NUMBER : T_NIL; break;
      case T_USERDATA:  tag(top-1) = (uvalue(l) == uvalue(r)) ? T_NUMBER : T_NIL; break;
      case T_STRING:    tag(top-1) = (strcmp (svalue(l), svalue(r)) == 0) ? T_NUMBER : T_NIL; break;
      case T_MARK:      return 1;
     }
    }
    nvalue(top-1) = 1;
   }
   break;

EQOP 명령어는 “같다” 연산자를 처리하는 명령어입니다. 일단 비교하는 두 값의 타입이 다르면 비교를 안하네요. 타입이 같으면 문자열을 제외하고 나머지는 C 언어 기준으로 같음을 비교합니다. 즉, 루아 입장에서는 레퍼런스의 값이 같아야 같은 것으로 처리됩니다. 예를 들어 루아 배열 같은 경우, 배열에 소속된 값이 모두 같더라도 두 배열이 서로 다른 레퍼런스라면 (두 배열이 서로 다른 메모리 위치에 할당되 있다면) 다른 배열로 취급한다는 겁니다. 문자열은 문자열 내용을 비교합니다.

   case LTOP:
   {
    Object *l = top-2;
    Object *r = top-1;
    --top;
    if (tag(l) == T_NUMBER && tag(r) == T_NUMBER)
     tag(top-1) = (nvalue(l) < nvalue(r)) ? T_NUMBER : T_NIL;
    else
    {
     if (tostring(l) || tostring(r))
      return 1;
     tag(top-1) = (strcmp (svalue(l), svalue(r)) < 0) ? T_NUMBER : T_NIL;
    }
    nvalue(top-1) = 1; 
   }
   break;
   
   case LEOP:
   {
    Object *l = top-2;
    Object *r = top-1;
    --top;
    if (tag(l) == T_NUMBER && tag(r) == T_NUMBER)
     tag(top-1) = (nvalue(l) <= nvalue(r)) ? T_NUMBER : T_NIL;
    else
    {
     if (tostring(l) || tostring(r))
      return 1;
     tag(top-1) = (strcmp (svalue(l), svalue(r)) <= 0) ? T_NUMBER : T_NIL;
    }
    nvalue(top-1) = 1; 
   }
   break;

LTOP와 LEOP 명령어 처리 코드는 거의 같습니다. “작다”와 “작거나 같다”를 처리하는 명령어 구현이라 그렇습니다. 연산자 말고는 다를게 없지요. 일단 number 타입은 바로 비교해서 결과를 스택에 넣습니다. number 타입이 아닐 때는 문자열로 변경하는데, 문자열로 변경을 실패하면 비교를 안하네요. 솔직히 좀 보완해야 하지 않나하는 생각이 듭니다.

   case ADDOP:
   {
    Object *l = top-2;
    Object *r = top-1;
    if (tonumber(r) || tonumber(l))
     return 1;
    nvalue(l) += nvalue(r);
    --top;
   }
   break; 
   
   case SUBOP:
   {
    Object *l = top-2;
    Object *r = top-1;
    if (tonumber(r) || tonumber(l))
     return 1;
    nvalue(l) -= nvalue(r);
    --top;
   }
   break; 
   
   case MULTOP:
   {
    Object *l = top-2;
    Object *r = top-1;
    if (tonumber(r) || tonumber(l))
     return 1;
    nvalue(l) *= nvalue(r);
    --top;
   }
   break; 
   
   case DIVOP:
   {
    Object *l = top-2;
    Object *r = top-1;
    if (tonumber(r) || tonumber(l))
     return 1;
    nvalue(l) /= nvalue(r);
    --top;
   }
   break; 

딱히 설명이 더 필요없는 기본 사칙연산 명령어 구현입니다.

   case CONCOP:
   {
    Object *l = top-2;
    Object *r = top-1;
    if (tostring(r) || tostring(l))
     return 1;
    svalue(l) = lua_createstring (lua_strconc(svalue(l),svalue(r)));
    if (svalue(l) == NULL)
     return 1;
    --top;
   }
   break;

문자열 두 개를 합치는 루아 연산자를 구현한 코드입니다. 구현 자체는 간단합니다. 문자열 두개를 합쳐서 새로운 문열로 만들어서 스택에 다시 넣습니다.

   case MINUSOP:
    if (tonumber(top-1))
     return 1;
    nvalue(top-1) = - nvalue(top-1);
   break; 
   
   case NOTOP:
    tag(top-1) = tag(top-1) == T_NIL ? T_NUMBER : T_NIL;
   break;

숫자나 변수 압에 마이너스(-)를 붙이면 역수로 바꿉니다. 역시 기본적인 수학 연산자 구현입니다. NOTOP는 코드 구현만 보면 스택 꼭대기에 있는 값이 nil이면 number로 바꾸고 아니면 nil로 바꾸는 동작을 합니다. 동작을 보니 C언어의 not 연산자(~ 연산자)는 아닌 것 같네요. 그렇다면 흔히 nonop라고 많이 부르는 아무일 안하는 연산자로 볼 수도 있는데 그러기엔 타입 변경을 하네요.

   case ONTJMP:
   {
    CodeWord code;
    get_word(code,pc);
    if (tag(top-1) != T_NIL) pc += code.w;
   }
   break;
   
   case ONFJMP:	   
   {
    CodeWord code;
    get_word(code,pc);
    if (tag(top-1) == T_NIL) pc += code.w;
   }
   break;
   
   case JMP:
   {
    CodeWord code;
    get_word(code,pc);
    pc += code.w;
   }
   break;
    
   case UPJMP:
   {
    CodeWord code;
    get_word(code,pc);
    pc -= code.w;
   }
   break;
   
   case IFFJMP:
   {
    CodeWord code;
    get_word(code,pc);
    top--;
    if (tag(top) == T_NIL) pc += code.w;
   }
   break;

   case IFFUPJMP:
   {
    CodeWord code;
    get_word(code,pc);
    top--;
    if (tag(top) == T_NIL) pc -= code.w;
   }
   break;

다음은 점프 계열 명령어들입니다. ONTJMP는 스택 꼭대기 값이 nil이 아닐 때 점프합니다. ONFJMP는 스택 꼭대기 값이 nil일 때 점프합니다. JMP는 무조건 점프합니다. UPJMP는 바이트 코드 스택을 거슬러서 (역방향) 점프합니다. IFFJMP는 기본적으로 ONFJMP와 같은 동작을 하지만 스택 top 인덱스를 하나 내립니다. IFFUPJMP는 스택 top 인덱스를 하나 내리면서 역방향으로 점프합니다.

     case POP: --top; break;

그냥 스택 top을 하나 내립니다.

   case CALLFUNC:
   {
    Byte *newpc;
    Object *b = top-1;
    while (tag(b) != T_MARK) b--;
    if (tag(b-1) == T_FUNCTION)
    {
     lua_debugline = 0;			/* always reset debug flag */
     newpc = bvalue(b-1);
     bvalue(b-1) = pc;		        /* store return code */
     nvalue(b) = (base-stack);		/* store base value */
     base = b+1;
     pc = newpc;
     if (MAXSTACK-(base-stack) < STACKGAP)
     {
      lua_error ("stack overflow");
      return 1;
     }
    }
    else if (tag(b-1) == T_CFUNCTION)
    {
     int nparam; 
     lua_debugline = 0;			/* always reset debug flag */
     nvalue(b) = (base-stack);		/* store base value */
     base = b+1;
     nparam = top-base;			/* number of parameters */
     (fvalue(b-1))();			/* call C function */
     
     /* shift returned values */
     { 
      int i;
      int nretval = top - base - nparam;
      top = base - 2;
      base = stack + (int) nvalue(base-1);
      for (i=0; i<nretval; i++)
      {
       *top = *(top+nparam+2);
       ++top;
      }
     }
    }
    else
    {
     lua_reportbug ("call expression not a function");
     return 1;
    }
   }
   break;

명령어 이름이 동작을 설명합니다. 함수를 호출하는 명령어입니다. 크게 두 부분으로 나눴습니다. 하나는 루아 함수 호출이고 다른 하나는 C 함수 호출입니다. 루아 함수를 호출하면 스택에서 Object 타입 인스턴스를 하나 꺼냅니다. 이 인스턴스는 함수 자체의 바이트 코드 스택 위치를 포인터로 가지고 있지요. 이 포인터에서 함수 바이트 코드를 가져와서 현재 프로그램 카운터를 옮깁니다. 동작 흐름은 이런식으로 파악이 되긴하는데 구체적으로 왜 코드에서 숫자와 변수를 저렇게 사용했는지는 그림을 그려가며 분석해야 할 것 같습니다. 그러면 읽는데 시간이 너무 걸리므로 흐름을 파악했다는 것에 만족하겠습니다. C 함수 호출 할 때는 일단 함수 포인터로 바로 점프해서 C 함수를 내부적으로 호출하는 코드가 보입니다. 그리고 C 함수에서 보낸 리턴값을 스택에서 정리하는 코드가 나옵니다. 이부분은 나중에 진지하게 봐야 할 것 같습니다. 눈으로만 봐서는 코드 의미 파악이 안되는군요.

   case RETCODE:
   {
    int i;
    int shift = *pc++;
    int nretval = top - base - shift;
    top = base - 2;
    pc = bvalue(base-2);
    base = stack + (int) nvalue(base-1);
    for (i=0; i<nretval; i++)
    {
     *top = *(top+shift+2);
     ++top;
    }
   }
   break;
   
   case HALT:
    base = oldbase;
   return 0;		/* success */

RETCODE는 함수 리턴값을 처리하는 명령어 구현입니다. 루아는 값을 여러개 리턴할 수 있습니다. 그래서 for 문을 돌면서 스택에 리턴값을 쌓는 코드가 보입니다. 바이트 코드와 스택 사이에서 리턴값을 어떻게 핸들링하는지는 바로 파악이 안됩니다. 이 글을 계속 읽은 분은 이제 아시겠죠? 제가 이해 안될 때는 어떻게 하는지. 그냥 넘어갑니다. HALT는 말 그대로 그냥 종료입니다.

   case SETFUNCTION:
   {
    CodeWord file, func;
    get_word(file,pc);
    get_word(func,pc);
    if (lua_pushfunction (file.w, func.w))
     return 1;
   }
   break;
   
   case SETLINE:
   {
    CodeWord code;
    get_word(code,pc);
    lua_debugline = code.w;
   }
   break;
   
   case RESET:
    lua_popfunction ();
   break;
   
   default:
    lua_error ("internal error - opcode didn't match");
   return 1;
  }
 }
}

SETFUNCTION는 루아 디버깅 정보 출력용 명령어입니다. 함수를 선언할 때 함수 정보를 루아 디버깅 정보 기록용 배열에 넣습니다. SETLINE 명령어도 마찬가지로 디버깅 정보 출력용입니다. RESET도 디버깅 정보 처리용입니다. 함수 동작이 끝날 때 디버깅 정보 배열에서 마지막 함수 정보를 제거합니다.

이렇게해서 무지하게 긴 루아 opcode 처리 함수가 끝났습니다. 알게된 것보다 모르는게 더 많긴하지만 조금이라도 뭔가를 알았다는 것이 더 중요하지요. 어차피 다음 릴리즈에서 또 읽을텐데요. 그 때는 지금보다 더 많이 안 상태에서 코드를 읽을 수 있길 바라며…

그런데 아직 Opcode.c 안끝났어요. 밑에 함수 구현 더 있습니다.

댓글남기기