Inside Python’s VM: How Stack Frames Execute Bytecode
The article dissects Python's virtual machine execution flow, showing that bytecode runs inside dynamically created stack‑frame objects rather than the static PyCodeObject, and explains the internal structures, memory optimizations, and debugging interfaces that make this possible.
Prologue
Python code is first compiled into a PyCodeObject. The virtual machine then reads the bytecode from this object and executes it in a runtime context, not directly on the PyCodeObject itself.
Stack Frame: The VM Execution Environment
Because the static PyCodeObject cannot hold dynamic execution state, the VM creates a stack frame for each execution context. The frame stores variable values, the current instruction pointer, and other runtime metadata.
name = "古明地觉"
def some_func():
name = "八意永琳"
print(name)
some_func()
print(name)Both print(name) statements compile to identical bytecode, yet they produce different outputs because each runs in a distinct stack frame with its own name binding.
Execution Flow Illustrated
When a module is loaded, the VM creates a top‑level frame A from the module’s PyCodeObject.
Calling some_func causes the VM to push a new frame B on top of A .
Frame B has its own name variable, independent of the one in A .
After some_func returns, frame B is destroyed and execution resumes in frame A .
VM vs. Operating System
The VM mimics an OS loader and scheduler:
Program loading : OS loads an executable into memory; the VM loads a .pyc file and creates a PyCodeObject pointer.
Memory management : OS manages process heap and stack; the VM manages Python objects and garbage collection.
Instruction execution : CPU executes machine instructions; the VM executes bytecode instructions.
Resource management : OS handles file handles and sockets; the VM handles Python‑level file objects and sockets.
Exception handling : OS deals with hardware interrupts; the VM catches and processes Python exceptions.
CPU Registers and Stack Frames
On x64, RSP (stack pointer) and RBP (base pointer) maintain the call chain. The VM mirrors this with the f_back field of a frame, linking each frame to its caller.
#include <stdio.h>
int add(int a, int b) {
int c = a + b;
return c;
}
int main() {
int a = 11;
int b = 22;
int result = add(a, b);
printf("a + b = %d
", result);
}When add is called, a new frame is created; after it returns, the VM restores RSP / RBP equivalents and continues in the caller frame.
Internal Stack‑Frame Structures
Historically the VM stored all fields in PyFrameObject. Since Python 3.11, performance‑critical fields are extracted into a lightweight _PyInterpreterFrame, reducing memory usage.
typedef struct _frame PyFrameObject;
struct _frame {
PyObject_HEAD
PyFrameObject *f_back;
struct _PyInterpreterFrame *f_frame;
PyObject *f_trace;
int f_lineno;
char f_trace_lines;
char f_trace_opcodes;
char f_fast_as_locals;
};
typedef struct _PyInterpreterFrame {
PyCodeObject *f_code;
struct _PyInterpreterFrame *previous;
PyObject *f_funcobj;
PyObject *f_globals;
PyObject *f_builtins;
PyObject *f_locals;
PyFrameObject *frame_obj;
_Py_CODEUNIT *prev_instr;
int stacktop;
uint16_t return_offset;
char owner;
PyObject *localsplus[1];
} _PyInterpreterFrame;The lightweight frame contains only the data needed for execution; the full PyFrameObject is allocated only when the interpreter must expose complete frame information to Python code.
Accessing Frames from Python
Frames can be obtained via inspect.currentframe(). The f_back attribute walks the call chain, and fields such as f_lineno, f_code.co_name, and f_globals expose runtime details.
import inspect
def foo():
return inspect.currentframe()
frame = foo()
print(frame) # <frame at 0x..., file '.../main.py', line 6, code foo>
print(frame.f_back) # <frame at 0x..., file '.../main.py', line 12, code <module>>
print(frame.f_back.f_back) # NoneTracing functions can be installed with sys.settrace to receive line‑level ( f_trace_lines) or opcode‑level ( f_trace_opcodes) callbacks, useful for fine‑grained debugging.
import sys
def trace_lines(frame, event, arg):
print(f"Line {frame.f_lineno}, file {frame.f_code.co_filename}")
return trace_lines
sys.settrace(trace_lines)Field‑by‑Field Walkthrough
PyObject_HEAD : object header, marking the frame as a Python object.
f_back : pointer to the caller’s frame, analogous to the OS’s RBP link.
f_frame : points to the lightweight _PyInterpreterFrame.
f_trace : optional debugging hook.
f_lineno : source line number of the current instruction.
f_trace_lines / f_trace_opcodes : enable line‑ or opcode‑level tracing.
f_fast_as_locals : marks whether locals have been materialized into a dict.
f_funcobj, f_globals, f_builtins, f_locals : references to the function object, global namespace dict, built‑ins dict, and (conceptual) locals dict.
frame_obj : back‑reference to the full PyFrameObject.
prev_instr : points to the previously executed bytecode unit (2‑byte units).
stacktop : offset of the runtime stack top within localsplus.
return_offset : pre‑computed offset for the RETURN instruction, allowing fast jumps after a call.
owner : indicates whether the frame lives on the VM stack or is heap‑allocated.
localsplus : flexible array holding locals, cell variables, free variables, and the evaluation stack; size is determined at runtime.
Conclusion
Python’s virtual machine does not execute bytecode directly on the static PyCodeObject. Instead, it builds a dynamic PyFrameObject (or the lighter _PyInterpreterFrame) for each execution context, linking frames together via f_back to form a call chain that mirrors the OS stack‑frame mechanism.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Satori Komeiji's Programming Classroom
Python and Rust developer; I write about any topics you're interested in. Follow me! (#^.^#)
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
