深入理解python虛擬機器:程式執行的載體——棧幀

一無是處的研究僧發表於2023-04-25

深入理解python虛擬機器:程式執行的載體——棧幀

棧幀(Stack Frame)是 Python 虛擬機器中程式執行的載體之一,也是 Python 中的一種執行上下文。每當 Python 執行一個函式或方法時,都會建立一個棧幀來表示當前的函式呼叫,並將其壓入一個稱為呼叫棧(Call Stack)的資料結構中。呼叫棧是一個後進先出(LIFO)的資料結構,用於管理程式中的函式呼叫關係。

棧幀的建立和銷燬是動態的,隨著函式的呼叫和返回而不斷髮生。當一個函式被呼叫時,一個新的棧幀會被建立並推入呼叫棧,當函式呼叫結束後,對應的棧幀會從呼叫棧中彈出並銷燬。

棧幀的使用使得 Python 能夠實現函式的巢狀呼叫和遞迴呼叫。透過不斷地建立和銷燬棧幀,Python 能夠跟蹤函式呼叫關係,儲存和恢復區域性變數的值,實現函式的巢狀和遞迴執行。同時,棧幀還可以用於實現異常處理、除錯資訊的收集和最佳化技術等。

需要注意的是,棧幀是有限制的,Python 直譯器會對棧幀的數量和大小進行限制,以防止棧溢位和資源耗盡的情況發生。在編寫 Python 程式時,合理使用函式呼叫和棧幀可以幫助提高程式的效能和可維護性。

棧幀資料結構

typedef struct _frame {
    PyObject_VAR_HEAD
    struct _frame *f_back;      /* previous frame, or NULL */
    PyCodeObject *f_code;       /* code segment */
    PyObject *f_builtins;       /* builtin symbol table (PyDictObject) */
    PyObject *f_globals;        /* global symbol table (PyDictObject) */
    PyObject *f_locals;         /* local symbol table (any mapping) */
    PyObject **f_valuestack;    /* points after the last local */
    /* Next free slot in f_valuestack.  Frame creation sets to f_valuestack.
       Frame evaluation usually NULLs it, but a frame that yields sets it
       to the current stack top. */
    PyObject **f_stacktop;
    PyObject *f_trace;          /* Trace function */

    /* In a generator, we need to be able to swap between the exception
       state inside the generator and the exception state of the calling
       frame (which shouldn't be impacted when the generator "yields"
       from an except handler).
       These three fields exist exactly for that, and are unused for
       non-generator frames. See the save_exc_state and swap_exc_state
       functions in ceval.c for details of their use. */
    PyObject *f_exc_type, *f_exc_value, *f_exc_traceback;
    /* Borrowed reference to a generator, or NULL */
    PyObject *f_gen;

    int f_lasti;                /* Last instruction if called */
    /* Call PyFrame_GetLineNumber() instead of reading this field
       directly.  As of 2.3 f_lineno is only valid when tracing is
       active (i.e. when f_trace is set).  At other times we use
       PyCode_Addr2Line to calculate the line from the current
       bytecode index. */
    int f_lineno;               /* Current line number */
    int f_iblock;               /* index in f_blockstack */
    char f_executing;           /* whether the frame is still executing */
    PyTryBlock f_blockstack[CO_MAXBLOCKS]; /* for try and loop blocks */
    PyObject *f_localsplus[1];  /* locals+stack, dynamically sized */
} PyFrameObject;

記憶體申請和棧幀的記憶體佈局

在 cpython 當中,當我們需要申請一個 frame object 物件的時候,首先需要申請記憶體空間,但是在申請記憶體空間的時候並不是單單申請一個 frameobject 大小的記憶體,而是會申請額外的記憶體空間,大致佈局如下所示。

  • f_localsplus,這是一個陣列使用者儲存函式執行的 local 變數,這樣可以直接透過下標得到對應的變數的值。
  • ncells 和 nfrees,這個變數和我們前面在分析 code object 的函式閉包相關,ncells 和 ncells 分別表示 cellvars 和 freevars 中變數的個數。
  • stack,這個變數就是函式執行的時候函式的棧幀,這個大小在編譯期間就可以確定因此可以直接確定棧空間的大小。

下面是在申請 frame object 的核心程式碼:

    Py_ssize_t extras, ncells, nfrees;
    ncells = PyTuple_GET_SIZE(code->co_cellvars); // 得到 co_cellvars 當中元素的個數 沒有的話則是 0
    nfrees = PyTuple_GET_SIZE(code->co_freevars); // 得到 co_freevars 當中元素的個數 沒有的話則是 0
    // extras 就是表示除了申請 frame object 自己的記憶體之後還需要額外申請多少個 指標物件
    // 確切的帶來說是用於儲存 PyObject 的指標
    extras = code->co_stacksize + code->co_nlocals + ncells +
        nfrees;
    if (free_list == NULL) {
        f = PyObject_GC_NewVar(PyFrameObject, &PyFrame_Type,
        extras);
        if (f == NULL) {
            Py_DECREF(builtins);
            return NULL;
        }
    }
    // 這個就是函式的 code object 物件 將其儲存到棧幀當中 f 就是棧幀物件
    f->f_code = code;
    extras = code->co_nlocals + ncells + nfrees;
    // 這個就是棧頂的位置 注意這裡加上的 extras 並不包含棧的大小
    f->f_valuestack = f->f_localsplus + extras;
    // 對額外申請的記憶體空間盡心初始化操作
    for (i=0; i<extras; i++)
        f->f_localsplus[i] = NULL;
    f->f_locals = NULL;
    f->f_trace = NULL;
    f->f_exc_type = f->f_exc_value = f->f_exc_traceback = NULL;

    f->f_stacktop = f->f_valuestack; // 將棧頂的指標指向棧的起始位置
    f->f_builtins = builtins;
    Py_XINCREF(back);
    f->f_back = back;
    Py_INCREF(code);
    Py_INCREF(globals);
    f->f_globals = globals;
    /* Most functions have CO_NEWLOCALS and CO_OPTIMIZED set. */
    if ((code->co_flags & (CO_NEWLOCALS | CO_OPTIMIZED)) ==
        (CO_NEWLOCALS | CO_OPTIMIZED))
        ; /* f_locals = NULL; will be set by PyFrame_FastToLocals() */
    else if (code->co_flags & CO_NEWLOCALS) {
        locals = PyDict_New();
        if (locals == NULL) {
            Py_DECREF(f);
            return NULL;
        }
        f->f_locals = locals;
    }
    else {
        if (locals == NULL)
            locals = globals;
        Py_INCREF(locals);
        f->f_locals = locals;
    }

    f->f_lasti = -1;
    f->f_lineno = code->co_firstlineno;
    f->f_iblock = 0;
    f->f_executing = 0;
    f->f_gen = NULL;

現在我們對 frame object 物件當中的各個欄位進行分析,說明他們的作用:

  • PyObject_VAR_HEAD:表示物件的頭部資訊,包括引用計數和型別資訊。
  • f_back:前一個棧幀物件的指標,或者為NULL。
  • f_code:指向 PyCodeObject 物件的指標,表示當前幀執行的程式碼段。
  • f_builtins:指向 PyDictObject 物件的指標,表示當前幀的內建符號表,字典物件,鍵是字串,值是對應的 python 物件。
  • f_globals:指向 PyDictObject 物件的指標,表示當前幀的全域性符號表。
  • f_locals:指向任意對映物件的指標,表示當前幀的區域性符號表。
  • f_valuestack:指向當前幀的值棧底部的指標。
  • f_stacktop:指向當前幀的值棧頂部的指標。
  • f_trace:指向跟蹤函式物件的指標,用於除錯和追蹤程式碼執行過程,這個欄位我們在後面的文章當中再進行分析。
  • f_exc_type、f_exc_value、f_exc_traceback:這個欄位和異常相關,在函式執行的時候可能會產生錯誤異常,這個就是用於處理異常相關的欄位。
  • f_gen:指向當前生成器物件的指標,如果當前幀不是生成器,則為NULL。
  • f_lasti:上一條指令在位元組碼當中的下標。
  • f_lineno:當前執行的程式碼行號。
  • f_iblock:當前執行的程式碼塊在f_blockstack中的索引,這個欄位也主要和異常的處理有關係。
  • f_executing:表示當前幀是否仍在執行。
  • f_blockstack:用於try和loop程式碼塊的堆疊,最多可以巢狀 CO_MAXBLOCKS 層。
  • f_localsplus:區域性變數和值棧的組合,是一個動態大小的陣列。

如果我們在一個函式當中呼叫另外一個函式,這個函式再呼叫其他函式就會形成函式的呼叫鏈,就會形成下圖所示的鏈式結構。

例子分析

我們現在來模擬一下下面的函式的執行過程。

import dis


def foo():
    a = 1
    b = 2
    return a + b


if __name__ == '__main__':
    dis.dis(foo)
    print(foo.__code__.co_stacksize)
    foo()

上面的 foo 函式的位元組碼如下所示:

  6           0 LOAD_CONST               1 (1)
              2 STORE_FAST               0 (a)

  7           4 LOAD_CONST               2 (2)
              6 STORE_FAST               1 (b)

  8           8 LOAD_FAST                0 (a)
             10 LOAD_FAST                1 (b)
             12 BINARY_ADD
             14 RETURN_VALUE

函式 foo 的 stacksize 等於 2 。

初始時 frameobject 的佈局如下所示:

現在執行第一條指令 LOAD_CONST 此時的 f_lasti 等於 -1,執行完這條位元組碼之後棧幀情況如下:

在執行完這條位元組碼之後 f_lasti 的值變成 0。位元組碼 LOAD_CONST 對應的 c 原始碼如下所示:

TARGET(LOAD_CONST) {
    PyObject *value = GETITEM(consts, oparg); // 從常量表當中取出下標為 oparg 的物件
    Py_INCREF(value);
    PUSH(value);
    FAST_DISPATCH();
}

首先是從 consts 將對應的常量拿出來,然後壓入棧空間當中。

再執行 STORE_FAST 指令,這個指令就是將棧頂的元素彈出然後儲存到前面提到的 f_localsplus 陣列當中去,那麼現在棧空間是空的。STORE_FAST 對應的 c 原始碼如下:

TARGET(STORE_FAST) {
    PyObject *value = POP(); // 將棧頂元素彈出
    SETLOCAL(oparg, value);  // 儲存到 f_localsplus 陣列當中去
    FAST_DISPATCH();
}

執行完這條指令之後 f_lasti 的值變成 2 。

接下來的兩條指令和上面的一樣,就不做分析了,在執行完兩條指令,f_lasti 變成 6 。

接下來兩條指令分別將 a b 載入進入棧空間單中現在棧空間佈局如下所示:

然後執行 BINARY_ADD 指令 彈出棧空間的兩個元素並且把他們進行相加操作,最後將得到的結果再壓回棧空間當中。

TARGET(BINARY_ADD) {
    PyObject *right = POP();
    PyObject *left = TOP();
    PyObject *sum;
    if (PyUnicode_CheckExact(left) &&
             PyUnicode_CheckExact(right)) {
        sum = unicode_concatenate(left, right, f, next_instr);
        /* unicode_concatenate consumed the ref to left */
    }
    else {
        sum = PyNumber_Add(left, right);
        Py_DECREF(left);
    }
    Py_DECREF(right);
    SET_TOP(sum); // 將結果壓入棧中
    if (sum == NULL)
        goto error;
    DISPATCH();
}

最後執行 RETURN_VALUE 指令將棧空間結果返回。

總結

在本篇文章當中主要介紹了 cpython 當中的函式執行的時候的棧幀結構,這裡麵包含的程式執行時候所需要的一些必要的變數,比如說全域性變數,python 內建的一些物件等等,同時需要注意的是 python 在查詢物件的時候如果本地 f_locals 沒有找到就會去全域性 f_globals 找,如果還沒有找到就會去 f_builtins 裡面的找,當一個程式返回的時候就會找到 f_back 他上一個執行的棧幀,將其設定成當前執行緒正在使用的棧幀,這就完成了函式的呼叫返回,關於這個棧幀還有一些其他的欄位我們沒有談到在後續的文章當中將繼續深入其中一些欄位。


本篇文章是深入理解 python 虛擬機器系列文章之一,文章地址:https://github.com/Chang-LeHung/dive-into-cpython

更多精彩內容合集可訪問專案:https://github.com/Chang-LeHung/CSCore

關注公眾號:一無是處的研究僧,瞭解更多計算機(Java、Python、計算機系統基礎、演算法與資料結構)知識。

相關文章