Python 소스코드가 실행되는 방법

‘파이썬은 인터프리터로 실행된다.’ 라는 말의 의미를 좀 더 자세히 들여다보면, 파이썬은 사실 내부적인 인터프리터 루프를 통해 파이썬 코드를 바이트코드로 컴파일하고 해당 바이트코드를 해석하는 방식으로 동작한다. 이러한 인터프리터 루프를 담당하는 주체가 바로 CPython이라 불리는 파이썬 구현(Python implementation)이다.

alt text

파이썬 소스코드(.py)는 Interpreter(CPython과 의미적으로 동치)에 의해 가장 먼저 컴파일 되는데 위 이미지에서는 생략됐지만 컴파일 과정은 AST(추상 구문 트리) 생성과 파이썬 바이트코드 생성으로 나누어 구분할 수 있다. 그리고 컴파일된 파이썬 바이트코드와 코드 실행에 필요한 각종 라이브러리 모듈을 Python virtual machine(PVM)으로 전달함으로써 파이썬 코드가 실행된다.

Example of compilation process in Python

파이썬 소스코드를 컴파일하는 과정에서 나오는 결과물은 다음과 같은 방법으로 확인할 수 있다. 해당 과정을 이미 이해하고 있다면 Python Bytecode를 설명하는 부분으로 바로 건너뛰어도 된다.

아래는 foo 함수를 호출하여 “Hello, World!” 문자열을 출력하는 간단한 예시 코드이다.

# example.py
def foo(name):
    message = f"Hello, {name}!"
    print(message)

foo("World")

이 예시 코드가 컴파일되는 과정을 가시적으로 확인하고 싶다면 아래 코드를 실행해보자. 아래 코드는 파이썬 내장 모듈을 이용해 AST(추상 구문 트리)와 파이썬 바이트코드 형태를 출력하는 코드와 그 결과이다.

// https://docs.python.org/3/library/functions.html#compile compile(source, filename, mode, flags=0, dont_inherit=False, optimize=-1)

Compile the source into a code or AST object. Code objects can be executed by exec() or eval(). source can either be a normal string, a byte string, or an AST object. Refer to the ast module documentation for information on how to work with AST objects.

import ast
import dis

with open('example.py', 'r') as f:
    source_code = f.read()

ast_object = ast.parse(source_code)
print("\n------------------------------ Python AST Tree ------------------------------")
print(ast.dump(ast_object, indent=4))

code_object = compile(ast_object, '<string>', 'exec')
print("\n------------------------------ Python Bytecode ------------------------------")
dis.dis(code_object)

print("------------------------------ Running code ------------------------------")
exec(code_object)
------------------------------ Python AST Tree ------------------------------
Module(
    body=[
        FunctionDef(
            name='foo',
            args=arguments(
                posonlyargs=[],
                args=[
                    arg(arg='name')],
                kwonlyargs=[],
                kw_defaults=[],
                defaults=[]),
            body=[
                Assign(
                    targets=[
                        Name(id='message', ctx=Store())],
                    value=JoinedStr(
                        values=[
                            Constant(value='Hello, '),
                            FormattedValue(
                                value=Name(id='name', ctx=Load()),
                                conversion=-1),
                            Constant(value='!')])),
                Expr(
                    value=Call(
                        func=Name(id='print', ctx=Load()),
                        args=[
                            Name(id='message', ctx=Load())],
                        keywords=[]))],
            decorator_list=[]),
        Expr(
            value=Call(
                func=Name(id='foo', ctx=Load()),
                args=[
                    Constant(value='World')],
                keywords=[]))],
    type_ignores=[])

------------------------------ Python Bytecode ------------------------------
  0           0 RESUME                   0

  2           2 LOAD_CONST               0 (<code object foo at 0x100f35a70, file "<string>", line 2>)
              4 MAKE_FUNCTION            0
              6 STORE_NAME               0 (foo)

  6           8 PUSH_NULL
             10 LOAD_NAME                0 (foo)
             12 LOAD_CONST               1 ('World')
             14 PRECALL                  1
             18 CALL                     1
             28 POP_TOP
             30 LOAD_CONST               2 (None)
             32 RETURN_VALUE

Disassembly of <code object foo at 0x100f35a70, file "<string>", line 2>:
  2           0 RESUME                   0

  3           2 LOAD_CONST               1 ('Hello, ')
              4 LOAD_FAST                0 (name)
              6 FORMAT_VALUE             0
              8 LOAD_CONST               2 ('!')
             10 BUILD_STRING             3
             12 STORE_FAST               1 (message)

  4          14 LOAD_GLOBAL              1 (NULL + print)
             26 LOAD_FAST                1 (message)
             28 PRECALL                  1
             32 CALL                     1
             42 POP_TOP
             44 LOAD_CONST               0 (None)
             46 RETURN_VALUE
------------------------------ Running code ------------------------------
Hello, World!

Python Bytecode

위와 같이 생성된 바이트코드는 바로 .pyc 파일 구조 내 Marshalled code object에 직렬화 된 상태로 존재하며, PVM에 의해 실행되는 코드의 정체이다. 예시 코드를 python -m py_compile example.py 명령어로 실행하면 동일 디렉터리에 __pycache__라는 폴더가 생성되는데, 해당 폴더를 살펴보면 example.cpython-XXX.pyc라는 파일이 생성되어 있을 것이다.

파이썬 바이트코드는 5개의 필드(컬럼)로 이루어져 있는데 각 필드는 아래 표와 매핑되는 정보를 나타낸다. 또한, PVM은 Stack-based이기 때문에 LOAD_CONST, LOAD_FAST, LOAD_GLOBAL 등의 Instruction들은 데이터(상수, 변수 값 등)을 가져와서 스택에 쌓는(Push) 역할을 한다.

Column Description Remarks
1 source code line -
2 bytecode offset bytecode gets decoded as a pair of bytes (opcode, oparg)
3 opcode LIST OF OPCODES
4 oparg argument that the opcode uses
5 resolved argument opcode tells the program which co_xxxx tuple to look for && oparg resolves the argument via co_xxxx[oparg]

.pyc file and PyCodeObject

alt text

.pyc 파일의 구조는 위 그림과 같다. .pyc 파일은 16 바이트의 Magic Header가 존재하고, 그 뒤에 Marshalling된 코드 오브젝트가 존재한다. 이 코드 오브젝트는 PyCodeObject 구조체의 각 필드를 순서대로 직렬화해서 저장하고 있다. PyCodeObject 구조체는, 파이썬 코드를 “컴파일” 했을 때 만들어지는 코드 객체(code object) 를 C언어 레벨에서 표현하는 자료구조이다.

In computer science, marshalling or marshaling (US spelling) is the process of transforming the memory representation of an object into a data format suitable for storage or transmission, especially between different runtimes.[citation needed] It is typically used when data must be moved between different parts of a computer program or from one program to another.

Marshalling simplifies complex communications, because it allows using composite objects instead of being restricted to primitive objects.

// https://github.com/python/cpython/blob/3.10/Include/cpython/code.h
struct PyCodeObject {
    PyObject_HEAD
    int co_argcount;            /* #arguments, except *args */
    int co_posonlyargcount;     /* #positional only arguments */
    int co_kwonlyargcount;      /* #keyword only arguments */
    int co_nlocals;             /* #local variables */
    int co_stacksize;           /* #entries needed for evaluation stack */
    int co_flags;               /* CO_..., see below */
    int co_firstlineno;         /* first source line number */
    PyObject *co_code;          /* instruction opcodes */
    PyObject *co_consts;        /* list (constants used) */
    PyObject *co_names;         /* list of strings (names used) */
    PyObject *co_varnames;      /* tuple of strings (local variable names) */
    PyObject *co_freevars;      /* tuple of strings (free variable names) */
    PyObject *co_cellvars;      /* tuple of strings (cell variable names) */
    Py_ssize_t *co_cell2arg;    /* Maps cell vars which are arguments. */
    PyObject *co_filename;      /* unicode (where it was loaded from) */
    PyObject *co_name;          /* unicode (name, for reference) */
    PyObject *co_linetable;     /* string (encoding addr<->lineno mapping) */
    // truncated ...
};

PyCodeObject바이트코드, 리터럴 값, 변수 및 속성 이름 등 런타임에 필요한 모든 부가 정보(메타데이터)를 모두 포함하고 있으며 파이썬 인터프리터에서 해당 객체 하나만 바라보게 함으로써 파이썬 코드의 실행 과정을 효율적이고 일관되게 유지하도록 보장한다. PyCodeObject에 포함된 필드 중에서 눈여겨 볼 필드는 아래 표와 같다.

Field Description
co_code python byte code
co_consts python literals (i.e. strings, integers etc)
co_names global variables, functions
co_varnames arguments & local variables
co_name name of code object (e.g. function name)
co_flags some obfuscators use custom flags
co_filename filename of compiled object

우리는 리버스 엔지니어링을 위해 파이썬 인터프리터가 Marshlled code object를 실제 실행 가능한 바이트코드로 되살려내는 과정을 이해해야 한다. Marshal 포맷은 각 객체 타입마다 고유한 타입 코드를 붙이고, 그 뒤에 크기와 내용을 기록하는 간단한 바이너리 프로토콜이다. 파이썬 내장 함수 marshal.load()는 파일로부터 바이너리 스트림을 읽어, 각 타입 코드(TYPE_CODE)별로 대응하는 C 레벨 구조체나 파이썬 객체를 생성한다.

앞선 과정을 통해 메모리에 온전한 PyCodeObject 구조체가 올라가고 그 안에 실제 바이트코드(co_code)와 각종 메타데이터들이 포함되어 있는 것이다. 추가적으로 파이썬 바이트코드는 “wordcode” 포맷(파이썬 3.6+)으로, 보통 한 명령어가 2바이트(opcode+oparg)씩 쪼개어 저장돼 있지만 EXTENDED_ARG나 Python 3.11의 adaptive opcode 등 특수한 경우 더 길어질 수도 있다.

이를 좀 더 직관적으로 이해하기 위해 간단한 스크립트를 통해 Marshalled code object에서 역직렬화를 수행하여 PyCodeObject를 복원하고, 바이트코드와 매핑하여 디스어셈블하는 과정을 살펴보자.

아래 결과는 교육 목적의 디스어셈블러 스크립트를 실행한 결과로, 앞서 생성한 example.cpython-XXX.pyc 파일을 대상으로 역직렬화를 수행하여 PyCodeObject를 복원하고 그 안에서 파이썬 바이트코드로 매핑한 과정을 직관적으로 보여준다.

=== PyCodeObject fields (<module>) ===
co_argcount: 0
co_cellvars: ()
co_code: b'\x97\x00d\x00\x84\x00Z\x00\x02\x00e\x00d\x01\xa6\x01\x00\x00\xab\x01\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00d\x02S\x00'
co_consts: (<code object foo at 0x1029abd20, file "example.py", line 2>, 'World', None)
co_exceptiontable: b''
co_filename: 'example.py'
co_firstlineno: 1
co_flags: 0
co_freevars: ()
co_kwonlyargcount: 0
co_lines: <built-in method co_lines of code object at 0x102949c30>
co_linetable: b'\xf0\x03\x01\x01\x01\xf0\x04\x02\x01\x13\xf0\x00\x02\x01\x13\xf0\x00\x02\x01\x13\xf0\x08\x00\x01\x04\x80\x03\x80G\x81\x0c\x84\x0c\x80\x0c\x80\x0c\x80\x0c'
co_lnotab: b'\x00\xff\x02\x02\x06\x04'
co_name: '<module>'
co_names: ('foo',)
co_nlocals: 0
co_positions: <built-in method co_positions of code object at 0x102949c30>
co_posonlyargcount: 0
co_qualname: '<module>'
co_stacksize: 3
co_varnames: ()

=== Bytecode Mapping (<module>) ===
  0 | 0x97 |  RESUME
  1 | 0x00
  2 | 0x64 |  LOAD_CONST <code object foo at 0x1029abd20, file "example.py", line 2>
  3 | 0x00
  4 | 0x84 |  MAKE_FUNCTION
  5 | 0x00
  6 | 0x5a |  STORE_NAME foo
  7 | 0x00
  8 | 0x02 |  PUSH_NULL
  9 | 0x00
 10 | 0x65 |  LOAD_NAME foo
 11 | 0x00
 12 | 0x64 |  LOAD_CONST 'World'
 13 | 0x01
 14 | 0xa6 |  PRECALL
 15 | 0x01
 16 | 0x00
 17 | 0x00
 18 | 0xab |  CALL
 19 | 0x01
 20 | 0x00
 21 | 0x00
 22 | 0x00
 23 | 0x00
 24 | 0x00
 25 | 0x00
 26 | 0x00
 27 | 0x00
 28 | 0x01 |  POP_TOP
 29 | 0x00
 30 | 0x64 |  LOAD_CONST None
 31 | 0x02
 32 | 0x53 |  RETURN_VALUE
 33 | 0x00

=== Disassembly with raw-bytes (<module>) ===
   0 | 97 00  | RESUME
   2 | 64 00  | LOAD_CONST           <code object foo at 0x1029abd20, file "example.py", line 2>
   4 | 84 00  | MAKE_FUNCTION
   6 | 5a 00  | STORE_NAME           foo
   8 | 02 00  | PUSH_NULL
  10 | 65 00  | LOAD_NAME            foo
  12 | 64 01  | LOAD_CONST           'World'
  14 | a6 01  | PRECALL
  18 | ab 01  | CALL
  28 | 01 00  | POP_TOP
  30 | 64 02  | LOAD_CONST           None
  32 | 53 00  | RETURN_VALUE

 Code object: <module> (Line 1)
   Constants:
     Code object: foo (Line 2)
       Constants:
          None
          'Hello, '
          '!'

      'World'
      None

    === PyCodeObject fields (foo) ===
    co_argcount: 1
    co_cellvars: ()
    co_code: b'\x97\x00d\x01|\x00\x9b\x00d\x02\x9d\x03}\x01t\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00|\x01\xa6\x01\x00\x00\xab\x01\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00d\x00S\x00'
    co_consts: (None, 'Hello, ', '!')
    co_exceptiontable: b''
    co_filename: 'example.py'
    co_firstlineno: 2
    co_flags: 3
    co_freevars: ()
    co_kwonlyargcount: 0
    co_lines: <built-in method co_lines of code object at 0x1029abd20>
    co_linetable: b"\x80\x00\xd8\x0e\x1f\x98\x04\xd0\x0e\x1f\xd0\x0e\x1f\xd0\x0e\x1f\x80G\xdd\x04\t\x88'\x81N\x84N\x80N\x80N\x80N"
    co_lnotab: b'\x02\x01\x0c\x01'
    co_name: 'foo'
    co_names: ('print',)
    co_nlocals: 2
    co_positions: <built-in method co_positions of code object at 0x1029abd20>
    co_posonlyargcount: 0
    co_qualname: 'foo'
    co_stacksize: 3
    co_varnames: ('name', 'message')

    === Bytecode Mapping (foo) ===
      0 | 0x97 |  RESUME
      1 | 0x00
      2 | 0x64 |  LOAD_CONST 'Hello, '
      3 | 0x01
      4 | 0x7c |  LOAD_FAST name
      5 | 0x00
      6 | 0x9b |  FORMAT_VALUE
      7 | 0x00
      8 | 0x64 |  LOAD_CONST '!'
      9 | 0x02
     10 | 0x9d |  BUILD_STRING
     11 | 0x03
     12 | 0x7d |  STORE_FAST message
     13 | 0x01
     14 | 0x74 |  LOAD_GLOBAL NULL + print
     15 | 0x01
     16 | 0x00
     17 | 0x00
     18 | 0x00
     19 | 0x00
     20 | 0x00
     21 | 0x00
     22 | 0x00
     23 | 0x00
     24 | 0x00
     25 | 0x00
     26 | 0x7c |  LOAD_FAST message
     27 | 0x01
     28 | 0xa6 |  PRECALL
     29 | 0x01
     30 | 0x00
     31 | 0x00
     32 | 0xab |  CALL
     33 | 0x01
     34 | 0x00
     35 | 0x00
     36 | 0x00
     37 | 0x00
     38 | 0x00
     39 | 0x00
     40 | 0x00
     41 | 0x00
     42 | 0x01 |  POP_TOP
     43 | 0x00
     44 | 0x64 |  LOAD_CONST None
     45 | 0x00
     46 | 0x53 |  RETURN_VALUE
     47 | 0x00

    === Disassembly with raw-bytes (foo) ===
       0 | 97 00  | RESUME
       2 | 64 01  | LOAD_CONST           'Hello, '
       4 | 7c 00  | LOAD_FAST            name
       6 | 9b 00  | FORMAT_VALUE
       8 | 64 02  | LOAD_CONST           '!'
      10 | 9d 03  | BUILD_STRING
      12 | 7d 01  | STORE_FAST           message
      14 | 74 01  | LOAD_GLOBAL          NULL + print
      26 | 7c 01  | LOAD_FAST            message
      28 | a6 01  | PRECALL
      32 | ab 01  | CALL
      42 | 01 00  | POP_TOP
      44 | 64 00  | LOAD_CONST           None
      46 | 53 00  | RETURN_VALUE

     Code object: foo (Line 2)
       Constants:
          None
          'Hello, '
          '!'

References

https://en.wikipedia.org/wiki/CPython

https://en.wikipedia.org/wiki/Foreign_function_interface

https://medium.com/@kaushik.k/internal-working-of-python-415572929e7a

https://nowave.it/python-bytecode-analysis-1.html

https://github.com/python/cpython/blob/main/Lib/importlib/_bootstrap_external.py#L242-L478

https://late.am/post/2012/03/26/exploring-python-code-objects.html

https://blog.svenskithesource.be/posts/code-objects/

https://github.com/BarakAharoni/pycDcode

https://www.synopsys.com/blogs/software-security/understanding-python-bytecode.html

https://harpaz.wordpress.com/2018/09/19/python-byte-code-part-2-the-stack/

https://devguide.python.org/internals/interpreter/#instruction-decoding

https://github.com/Svenskithesource/pySpy

https://github.com/Svenskithesource/awesome-python-re?tab=readme-ov-file