‘파이썬은 인터프리터로 실행된다.’ 라는 말의 의미를 좀 더 자세히 들여다보면, 파이썬은 사실 내부적인 인터프리터 루프를 통해 파이썬 코드를 바이트코드로 컴파일하고 해당 바이트코드를 해석하는 방식으로 동작한다. 이러한 인터프리터 루프를 담당하는 주체가 바로 CPython이라 불리는 파이썬 구현(Python implementation)이다.
파이썬 소스코드(.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()
oreval()
. source can either be a normal string, a byte string, or an AST object. Refer to theast
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
.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