Skip to main content

frameobject.c: The Python Frame Object

Objects/frameobject.c defines PyFrameObject, the Python-visible wrapper around _PyInterpreterFrame. Since CPython 3.11 the real activation record lives on a per-thread stack arena; PyFrameObject is a heap-allocated shell that only materializes when user code accesses sys._getframe() or a debugger inspects the stack. The two objects share lifetime through a back-pointer: the interpreter frame's frame_obj field points to the heap shell, and the shell's f_frame points back to the activation record.

Map

C symbolLines (approx.)Role
PyFrameObject struct1-60Heap wrapper: f_frame, f_trace, f_lineno, f_trace_lines, f_trace_opcodes
_PyFrame_New_NoTrack1109-1135Allocates a new PyFrameObject and links it to an interpreter frame
frame_getcode794-800f_code getter: delegates to f_frame->f_code
frame_getlineno843-860f_lineno getter: calls PyCode_Addr2Line on f_frame->prev_instr
frame_getlasti802-811f_lasti getter: converts prev_instr to a byte offset
frame_getlocals813-842f_locals getter: triggers fast-to-locals materialization
frame_getglobals879-887f_globals getter
frame_getbuiltins888-896f_builtins getter
frame_gettrace863-869f_trace getter
frame_settrace870-878f_trace setter
_PyFrame_FastToLocals(ceval.c)Copies fast locals into the f_locals dict before user access
frame_clear1163-1200GC clear: clears the trace ref and detaches from the activation record
frame_traverse1163-1200GC traverse: visits f_trace and the entire back-frame chain
PyFrame_GetLineNumber1242-1250Public C API: returns the current line number
PyFrame_Type1238-1270Type object singleton
FrameLocalsProxy1350-14503.14 addition: lazy dict-like proxy over localsplus (replaces eager copy)

Reading

The heap wrapper and the arena frame

Before 3.11 every call created a PyFrameObject on the heap. Since 3.11 the activation record (_PyInterpreterFrame) lives in a thread-local arena; PyFrameObject is only allocated when something like sys._getframe() forces materialization. The two objects refer to each other:

/* Include/internal/pycore_frame.h */
struct _PyInterpreterFrame {
PyObject *f_executable; /* code object */
struct _PyInterpreterFrame *previous;
PyObject *f_funcobj;
PyObject *f_globals;
PyObject *f_builtins;
PyObject *f_locals;
PyFrameObject *frame_obj; /* heap wrapper, may be NULL */
/* ... localsplus[] follows */
};

_PyFrame_New_NoTrack allocates the PyFrameObject, sets frame_obj, and returns the shell without running GC tracking (the caller does that when the frame escapes to user code).

f_locals materialization and FrameLocalsProxy

In CPython 3.11-3.13 frame_getlocals calls _PyFrame_FastToLocals, which iterates localsplus and copies live values into a PyDictObject. That dict is cached in f_frame->f_locals. The copy is eager and potentially expensive for large frames.

CPython 3.14 introduces FrameLocalsProxy: a dict-like object that reads directly from localsplus on each access. The proxy is returned from f_locals without copying:

/* Objects/frameobject.c ~1350 (3.14) */
static PyObject *
frame_getlocals(PyFrameObject *f, void *Py_UNUSED(ignored))
{
if (PyFrameLocalsProxy_Check(f->f_locals)) {
Py_INCREF(f->f_locals);
return f->f_locals;
}
return _PyFrameLocalsProxy_New(f);
}

frame_getlineno

f_lineno is computed on demand from the co-location table (co_linetable) rather than stored as a plain integer. The getter calls PyCode_Addr2Line with the prev_instr offset:

/* Objects/frameobject.c:843 frame_getlineno */
static PyObject *
frame_getlineno(PyFrameObject *f, void *Py_UNUSED(ignored))
{
int lineno = PyFrame_GetLineNumber(f);
return PyLong_FromLong(lineno);
}

/* Objects/frameobject.c:1242 PyFrame_GetLineNumber */
int
PyFrame_GetLineNumber(PyFrameObject *f)
{
assert(f != NULL);
if (f->f_lineno != 0) {
return f->f_lineno; /* set by trace machinery */
}
return PyCode_Addr2Line(f->f_frame->f_code,
_PyInterpreterFrame_LASTI(f->f_frame));
}

gopy notes

gopy splits the same two-layer design across two packages.

frame.Frame in /Users/apple/github/tamnd/gopy/frame/frame.go is the activation record (_PyInterpreterFrame). It lives in chunk arenas via frame.Chunk, holds LocalsPlus, and is cleared by Frame.Clear (mirroring _PyFrame_Clear).

objects.Frame in /Users/apple/github/tamnd/gopy/objects/frame.go is the Python-level wrapper (PyFrameObject). It holds the activation record through the InterpreterFrame interface rather than a direct pointer, avoiding an import cycle (frame imports objects; objects cannot import frame).

objects.Frame.Lineno mirrors frame_getlineno by calling CoAddr2Location(code, lasti) (the gopy equivalent of PyCode_Addr2Line). NewFrame corresponds to _PyFrame_New_NoTrack. frameTraverse mirrors frame_traverse and walks the full back-chain by looping over FrameBack().

The FrameLocalsProxy is not yet ported. frame_getlocals currently returns f.interp.FrameLocals(), which maps to the activation record's Locals field (the pre-3.14 eager-copy path). Fast-locals materialization (_PyFrame_FastToLocals) is tracked under spec 1700 task #487.