虛擬機是怎么執(zhí)行字節(jié)碼的?背后都經歷了哪些過程
楔子
當解釋器啟動后,首先會進行運行時環(huán)境的初始化,注意這里的運行時環(huán)境,它和之前說的執(zhí)行環(huán)境是不同的概念。運行時環(huán)境是一個全局的概念,而執(zhí)行環(huán)境是一個棧幀。
關于運行時環(huán)境的初始化是一個很復雜的過程,涉及到 Python 進程、線程的創(chuàng)建,類型對象的完善等非常多的內容,我們暫時先不討論。這里就假設初始化動作已經完成,我們已經站在了虛擬機的門檻外面,只需要輕輕推動第一張骨牌,整個執(zhí)行過程就像多米諾骨牌一樣,一環(huán)扣一環(huán)地展開。
在介紹字節(jié)碼的時候我們說過,解釋器可以看成是:編譯器+虛擬機,編譯器負責將源代碼編譯成 PyCodeObject 對象,而虛擬機則負責執(zhí)行。整個過程如下:
圖片
所以我們的重點就是虛擬機是怎么執(zhí)行 PyCodeObject 對象的?整個過程是什么,掌握了這些,你對虛擬機會有一個更深的理解。
虛擬機的運行框架
在介紹棧幀的時候我們說過,Python 是一門動態(tài)語言,一個變量指向什么對象需要在運行時才能確定,這些信息不可能靜態(tài)存儲在 PyCodeObject 對象中。
所以虛擬機在運行時會基于 PyCodeObject 對象動態(tài)創(chuàng)建出一個棧幀對象,然后在棧幀里面執(zhí)行字節(jié)碼。而創(chuàng)建棧幀,主要使用以下兩個函數(shù):
// Python/ceval.c
/* 基于 PyCodeObject、全局名字空間、局部名字空間,創(chuàng)建棧幀
* 參數(shù)非常簡單,所以它一般適用于模塊這種參數(shù)不復雜的場景
* 我們說模塊也會對應一個棧幀,并且它位于棧幀鏈的最頂層
*/
PyObject *
PyEval_EvalCode(PyObject *co, PyObject *globals, PyObject *locals);
/* 相比 PyEval_EvalCode 多了很多的參數(shù)
* 比如里面有位置參數(shù)以及個數(shù),關鍵字參數(shù)以及個數(shù)
* 還有默認參數(shù)以及個數(shù),閉包等等,顯然它用于函數(shù)等復雜場景
* 注:此 API 已廢棄,內部不再使用
*/
PyObject *
PyEval_EvalCodeEx(PyObject *_co, PyObject *globals, PyObject *locals,
PyObject *const *args, int argcount,
PyObject *const *kws, int kwcount,
PyObject *const *defs, int defcount,
PyObject *kwdefs, PyObject *closure);
這兩個函數(shù)最終都會調用 _PyEval_Vector 函數(shù),這個函數(shù)內部的邏輯我們一會兒再看??傊坏瑢ο蟪跏蓟戤?,那么就要進行處理了,在棧幀中執(zhí)行字節(jié)碼(幀評估)。
關于在棧幀中執(zhí)行字節(jié)碼,虛擬機內部有兩個重要的 C 函數(shù)。
// Python/ceval.c
PyObject *
PyEval_EvalFrame(PyFrameObject *f)
{
PyThreadState *tstate = _PyThreadState_GET();
return _PyEval_EvalFrame(tstate, f->f_frame, 0);
}
PyObject *
PyEval_EvalFrameEx(PyFrameObject *f, int throwflag)
{
PyThreadState *tstate = _PyThreadState_GET();
return _PyEval_EvalFrame(tstate, f->f_frame, throwflag);
}
當然啦,上面這兩個函數(shù)屬于高層的封裝,最終都會調用 _PyEval_EvalFrame 函數(shù)。
// Include/internal/pycore_ceval.h
static inline PyObject*
_PyEval_EvalFrame(PyThreadState *tstate,
struct _PyInterpreterFrame *frame,
int throwflag)
{
EVAL_CALL_STAT_INC(EVAL_CALL_TOTAL);
// 如果 tstate->interp->eval_frame 為 NULL
// 會調用 _PyEval_EvalFrameDefault 函數(shù)執(zhí)行字節(jié)碼
if (tstate->interp->eval_frame == NULL) {
return _PyEval_EvalFrameDefault(tstate, frame, throwflag);
}
// 另外虛擬機也支持自定義,允許我們替換掉 _PyEval_EvalFrameDefault
// 只需將具體實現(xiàn)賦值給 tstate->interp->eval_frame 即可
// 因此這個做法就使得性能分析器、調試器等工具的實現(xiàn)成為可能
return tstate->interp->eval_frame(tstate, frame, throwflag);
}
因此虛擬機最終會通過 _PyEval_EvalFrameDefault 函數(shù)來完成字節(jié)碼的執(zhí)行。
目前出現(xiàn)了好幾個函數(shù),我們用一張圖來描述一下它們的關系:
圖片
當然啦,_PyEval_Vector 函數(shù)內部在拿到棧幀之后,其實會直接調用 _PyEval_EvalFrame。
// Python/ceval.c
PyObject *
_PyEval_Vector(PyThreadState *tstate, PyFunctionObject *func,
PyObject *locals,
PyObject* const* args, size_t argcount,
PyObject *kwnames)
{
// 解釋一下相關參數(shù)的含義
/* tstate:線程狀態(tài)對象(在后續(xù)的章節(jié)剖析)
* func:函數(shù)對象(在后續(xù)的章節(jié)剖析)
* locals:局部名字空間
* args:調用函數(shù)時傳遞的參數(shù)
* argcount:通過位置參數(shù)傳遞的參數(shù)的個數(shù)
* kwnames:通過關鍵字參數(shù)傳遞的參數(shù)的名稱
舉個例子,比如調用是 func(1, 2, 3, c=4, d=5)
那么 args 就是 (1, 2, 3, 4, 5)
argcount 就是 3,因為有 3 個參數(shù)通過位置參數(shù)的方式傳遞
kwname 則是 ("c", "d")
*/
// 增加引用計數(shù)
Py_INCREF(func);
Py_XINCREF(locals);
// 給通過位置參數(shù)傳遞的參數(shù),增加引用計數(shù)
for (size_t i = 0; i < argcount; i++) {
Py_INCREF(args[i]);
}
// 給通過關鍵字參數(shù)傳遞的參數(shù),增加引用計數(shù)
if (kwnames) {
Py_ssize_t kwcount = PyTuple_GET_SIZE(kwnames);
for (Py_ssize_t i = 0; i < kwcount; i++) {
Py_INCREF(args[i+argcount]);
}
}
// 為即將執(zhí)行的函數(shù)調用,創(chuàng)建一個新的 _PyInterpreterFrame 結構體實例
// 然后推入虛擬機為其準備的 Stack 中
_PyInterpreterFrame *frame = _PyEvalFramePushAndInit(
tstate, func, locals, args, argcount, kwnames);
if (frame == NULL) {
return NULL;
}
// 計數(shù)器,統(tǒng)計 _PyEval_Vector 的調用次數(shù),用于跟蹤,我們不用關心
EVAL_CALL_STAT_INC(EVAL_CALL_VECTOR);
// 調用 _PyEval_EvalFrame 函數(shù)
return _PyEval_EvalFrame(tstate, frame, 0);
}
然后在 _PyEval_EvalFrame 內部又會調用 _PyEval_EvalFrameDefault 函數(shù),整個過程非常好理解。
注:棧幀指的是 PyFrameObject,它是一個 Python 對象,而 _PyInterpreterFrame 是一個只包含棧幀核心信息的普通 C 結構體,前面我們介紹過的。
但為了描述方便,后續(xù)在提到 _PyInterpreterFrame 時,我們也會稱它為棧幀,這一點大家心里清楚就好。
所以不難發(fā)現(xiàn) _PyEval_EvalFrameDefault 函數(shù)是虛擬機運行的核心,該函數(shù)較為復雜,我們會在下一篇文章中分析它的具體實現(xiàn)。至于本篇文章就先從宏觀的角度來描述一下虛擬機執(zhí)行字節(jié)碼的流程,并對之前的內容做一個補充,將背后涉及的概念闡述一遍,這樣后續(xù)看源碼的時候也會事半功倍。
首先棧幀中有一個 f_code 字段,它指向 PyCodeObject 對象,該對象的 co_code 字段則保存著字節(jié)碼指令序列。而虛擬機執(zhí)行字節(jié)碼就是從頭到尾遍歷整個 co_code,對指令逐條執(zhí)行的過程。
另外也不要覺得字節(jié)碼指令(簡稱指令)有多神秘,說白了它就是個 uint8 整數(shù),而一個程序肯定會包含多條指令,它們整體便構成了指令集,或者指令序列。那么顯然,使用 bytes 對象來表示指令序列再合適不過了,如果站在 C 的角度,則就是一個普普通通的字符數(shù)組,一條指令就是一個字符、或者說一個整數(shù)。
另外我們說指令序列里面包含的不僅僅是指令,還有指令參數(shù),因為每個指令都會帶一個參數(shù)。因此索引為 0 2 4 6 8 ··· 的整數(shù)表示指令,索引為 1 3 5 7 9 ··· 的整數(shù)表示指令參數(shù)。
我們用 Python 來演示一下:
code_string = """
a = 1
b = 2
c = a + b
"""
code_object = compile(code_string, "<file>", "exec")
# 查看常量池
print(code_object.co_consts)
"""
(1, 2, None)
"""
# 查看符號表
print(code_object.co_names)
"""
('a', 'b', 'c')
"""
這些都比較簡單,再來看一下反編譯的結果,直接 dis.dis(code_object) 即可。
/* 常量池:(1, 2, None)
* 符號表:('a', 'b', 'c')
*/
// 第一列表示源代碼行號
// 第二列表示指令的偏移量
// 第三列表示指令,在 C 中它們都是宏,表示整數(shù)
// 第四列表示指令參數(shù)
// 第五列是 dis 模塊為了方便我們閱讀而補充的提示信息
0 0 RESUME 0
// 指令:LOAD_CONST,指令參數(shù):0
// 表示從常量池中加載索引為 0 的常量,并壓入運行時棧(關于運行時棧,一會兒詳細說明)
// 索引為 0 的常量顯然是 1,而括號里面的提示信息顯示的也是 1
2 2 LOAD_CONST 0 (1)
// 指令:STORE_NAME,指令參數(shù):0
// 表示從符號表中加載索引為 0 的符號,顯然結果是 "a"
// 然后彈出運行時棧的棧頂元素,顯然是上一條指令壓入的 1
// 將 "a" 和 1 組成鍵值對,存儲在當前的名字空間中
// 到此 a = 1 這條語句便完成了,或者說完成了變量和值的綁定
4 STORE_NAME 0 (a)
// 從常量池中加載索引為 1 的常量(結果是 2),并壓入運行時棧
3 6 LOAD_CONST 1 (2)
// 從符號表中加載索引為 1 的符號(結果是 "b")
// 然后從棧頂彈出元素 2,將 "b" 和 2 綁定起來
8 STORE_NAME 1 (b)
// 加載符號表中索引為 0 的符號對應的值,并壓入運行時棧
4 10 LOAD_NAME 0 (a)
// 加載符號表中索引為 1 的符號對應的值,并壓入運行時棧
12 LOAD_NAME 1 (b)
// 將運行時棧的兩個元素彈出,并執(zhí)行加法運算
// 將運算之后的結果(a + b)再壓入運行時棧
14 BINARY_OP 0 (+)
// 從符號表中加載索引為 2 的符號,結果是 "c"
// 將運行時棧的棧頂元素彈出,這里是 a + b 的運算結果
// 然后進行綁定,完成 c = a + b 這條賦值語句
18 STORE_NAME 2 (c)
// 從常量池中加載索引為 2 的元素并返回,有一個隱式的 return None
20 RETURN_CONST 2 (None)
這些指令的源碼實現(xiàn)后續(xù)都會說,但是不難發(fā)現(xiàn),程序的主干邏輯都體現(xiàn)在字節(jié)碼中,而依賴的信息則由其它字段來維護。所謂執(zhí)行源代碼,其實就是虛擬機執(zhí)行編譯之后的字節(jié)碼。通過遍歷 co_code,然后基于不同的指令,執(zhí)行不同的邏輯。
然后我們基于上面這些輸出信息,看看能否將字節(jié)碼指令集還原出來,當然在還原之前首先要知道這些指令代表的數(shù)值是多少。
圖片
下面我們來進行還原。
"""
0 RESUME 0
2 LOAD_CONST 0
4 STORE_NAME 0
6 LOAD_CONST 1
8 STORE_NAME 1
10 LOAD_NAME 0
12 LOAD_NAME 1
14 BINARY_OP 0 (+)
18 STORE_NAME 2
20 RETURN_CONST 2
"""
CACHE = 0
STORE_NAME = 90
LOAD_CONST = 100
LOAD_NAME = 101
RETURN_CONST = 121
BINARY_OP = 122
RESUME = 151
codes = [
RESUME, 0,
# a = 1
LOAD_CONST, 0,
STORE_NAME, 0,
# b = 2
LOAD_CONST, 1,
STORE_NAME, 1,
# c = a + b
LOAD_NAME, 0, # 加載 a
LOAD_NAME, 1, # 加載 b
BINARY_OP, 0, # 計算 a + b
# 從 dis 的輸出信息可以看到,索引為 16 的指令沒有顯示
# 這是因為索引為 16 的指令和相應的指令參數(shù)都是 0
# 也就是說,在將 c 和 a + b 綁定之前,插入了一條 CACHE 指令
# 這條指令是做什么的,我們后續(xù)再探討
CACHE, 0,
STORE_NAME, 2,
# 所有代碼塊都隱式地包含了一個 return None
RETURN_CONST, 2
]
print(bytes(codes))
"""
b'\x97\x00d\x00Z\x00d\x01Z\x01e\x00e\x01z\x00\x00\x00Z\x02y\x02'
"""
那么字節(jié)碼是不是我們還原的這個樣子呢?來對比一下。
結果是一樣的,到此相信你對 Python 源代碼的執(zhí)行過程應該有更深的了解了,簡單來講,其實就是以下幾個步驟。
1)源代碼被編譯成 PyCodeObject 對象,該對象的 co_code 字段指向字節(jié)碼指令序列,它包含了程序執(zhí)行的主干邏輯,剩余字段則保存常量池、符號表等其它靜態(tài)信息。
2)虛擬機在 PyCodeObject 對象的基礎上構建棧楨對象。
3)虛擬機在棧楨對象內部執(zhí)行字節(jié)碼(幀評估),具體流程就是遍歷指令集和,根據(jù)不同指令執(zhí)行不同的處理邏輯,而這一過程便由 _PyEval_EvalFrameDefault 函數(shù)負責完成。
什么是運行時棧
之前一直提到一個概念,叫運行時棧,那什么是運行時棧呢?別急,我們先來回顧一下棧楨的基本結構。
圖片
大部分字段都很好理解,因為之前通過 Python 代碼演示過。但有幾個字段是虛擬機用于執(zhí)行指令的,后續(xù)會遇到,所以這里再拿出來解釋一下。
prev_instr
指向上一條剛執(zhí)行完的字節(jié)碼指令,因為每個指令要帶一個參數(shù),所以該字段的類型為 uint16 *,前 8 字節(jié)表示指令,后 8 字節(jié)表示參數(shù)。假設虛擬機要執(zhí)行偏移量為 14 的指令,那么 prev_instr 便指向偏移量為 12 的指令。
localsplus
一個柔性數(shù)組,它的內存大小被分為 4 個部分。
注:localsplus 是一個數(shù)組,所以它是一段連續(xù)的內存,只不過按照用途被分成了 4 個部分。如果用新一團團長丁偉的說法:每個部分之間是雞犬相聞,但又老死不相往來。
然后再著重強調一下運行時棧,虛擬機在執(zhí)行字節(jié)碼指令時高度依賴它,因為一個指令只能帶一個參數(shù),那么剩余的參數(shù)就必須通過運行時棧給出。比如 a = 1 會對應兩條字節(jié)碼:LOAD_CONST 和 STORE_NAME。
STORE_NAME 的作用是從符號表中獲取符號,或者說變量名,然后和值綁定起來。而要加載符號,那么必須要知道它在符號表中的索引,顯然這可以通過指令參數(shù)給出,但問題是與之綁定的值怎么獲取?
毫無疑問,要通過運行時棧,因此 LOAD_CONST 將值讀取進來之后,還要壓入運行時棧。然后 STORE_NAME 會將值從運行時棧中彈出,從而完成符號(變量)和值的綁定。關于運行時棧,我們再看個復雜的例子:
圖片
偏移量為 8 的指令表示要構建一個字典,指令參數(shù) 2 表示構建的字典的長度為 2,但問題是字典的鍵值對在什么地方?顯然它們已經被提前壓入了運行時棧,執(zhí)行 BUILD_CONST_KEY_MAP 的時候直接彈出即可。
所以這就是運行時棧的作用,如果某個指令需要 n 個參數(shù),那么其中的 n - 1 個必須要提前壓入運行時棧,然后在該指令執(zhí)行的時候依次彈出,因為一個指令只能帶一個參數(shù)。
stacktop
運行時棧的棧頂相對于 localsplus 數(shù)組的偏移量,那么顯然 localsplus 加上 stacktop 便指向運行時棧的棧頂。
我們通過源碼驗證一下:
// Inlcude/internal/pycore_frame.h
// 獲取 localsplus 字段
static inline PyObject**
_PyFrame_GetLocalsArray(_PyInterpreterFrame *frame)
{
return frame->localsplus;
}
// 獲取指針(PyObject **),它指向運行時棧的棧頂,我們稱之為 stack_pointer
// 而元素的入棧和出棧,顯然都是通過操作 stack_pointer 完成的
// 執(zhí)行 *stack_pointer++ = v,一個元素就入棧了
// 執(zhí)行 v = *--stack_pointer,一個元素就出棧了
static inline PyObject**
_PyFrame_GetStackPointer(_PyInterpreterFrame *frame)
{
// localsplus + stacktop 便指向運行時棧的棧頂
// 因為 stacktop 的含義就是棧頂相對于 localsplus 數(shù)組(首元素的地址)的偏移量
PyObject **sp = frame->localsplus + frame->stacktop;
frame->stacktop = -1;
return sp;
}
// 隨著元素的入棧和出棧,運行時棧的棧頂、或者說 stack_pointer 也在不斷變化
// 而 frame->stacktop 表示棧頂相對于 localsplus 的偏移量
// 那么顯然,由于棧頂發(fā)生變化,后續(xù)還要對 frame->stacktop 進行更新
static inline void
_PyFrame_SetStackPointer(_PyInterpreterFrame *frame, PyObject **stack_pointer)
{
frame->stacktop = (int)(stack_pointer - frame->localsplus);
}
這就是 stacktop 字段的作用,用于獲取運行時棧的棧頂指針。另外我們說 localsplus 數(shù)組被分成了 4 份,最后一份給了運行時棧,因此雖然我們稱之為棧,但它其實就是一個數(shù)組,而且還是數(shù)組的一部分。
而對于數(shù)組而言,內存地址從左往右是增大的。
這是 localsplus 的示意圖,我們只看運行時棧,目前棧里面有兩個元素,stack_pointer 指向棧頂。
這時如果要添加一個元素 3,那么直接 *stack_pointer++ = 3 即可。
如果要將棧頂元素彈出,那么執(zhí)行 v = *--stack_pointer 即可。
圖片
而 stack_pointer - localsplus 便是 stacktop。
運行時棧的一些 API
相信你對運行時棧的了解已經足夠深了,說白了它就是參數(shù)的容身之所,比如虛擬機在執(zhí)行 a + b 的時候,通過指令參數(shù)可以判斷這是一個加法操作。但在執(zhí)行加法的時候,加號兩邊的值要怎么獲取呢?這時候就需要一個棧來專門保存相應的參數(shù)。
在執(zhí)行加法之前,先將 a 和 b 壓入棧中,等執(zhí)行加法的時候,再將 a 和 b 從棧里面彈出來即可,非常簡單。
然后再來看看運行時棧相關的一些 API。
圖片
API 非常多,它們都定義在 Python/ceval_macros.h 中,我們來逐一介紹。假設目前運行時棧內部有三個元素,從棧底到棧頂分別是整數(shù) 1、2、3,那么運行時棧的結構就是下面這樣。
圖片
localsplus 數(shù)組被分成 4 個區(qū)域,運行時棧占據(jù)最后一個,因此圖中的灰色區(qū)域便是運行時棧之前的內存。由于我們是研究運行時棧,所以這部分區(qū)域后續(xù)就不再畫了。
然后看一下這些和運行時棧相關的 API 都是干嘛的。
STACK_LEVEL()
返回運行時棧的元素個數(shù),那么問題來了,通過 stack_pointer 可以獲取棧頂指針,但棧底指針怎么獲取呢?畢竟有了棧底指針,兩者相減就完事了。
#define STACK_LEVEL() ((int)(stack_pointer - _PyFrame_Stackbase(frame)))
顯然 _PyFrame_Stackbase 負責獲取運行時棧的棧底(基址),看一下它的具體實現(xiàn)。
// Include/internal/pycore_frame.h
// PyCodeObject 對象里面有以下幾個字段
// co_nlocals:代碼塊中局部變量的個數(shù),也包括參數(shù)
// co_ncellvars:cell 變量的個數(shù),即 co_cellvars 的長度
// co_nfreevars:free 變量的個數(shù),即 co_freevars 的長度
// co_nlocalsplus:局部變量、cell 變量、free 變量的個數(shù)之和
static inline PyObject **_PyFrame_Stackbase(_PyInterpreterFrame *f) {
// 由于 co_nlocalsplus = co_nlocals + co_ncellvars + co_nfreevars
// 那么顯然,f->localsplus + f->f_code->co_nlocalsplus
// 便指向運行時棧的棧底,或者說運行時棧對應的內存區(qū)域的首元素
return f->localsplus + f->f_code->co_nlocalsplus;
}
在之前的版本中(比如 3.8),棧楨內部會有一個 f_valuestack 字段,專門負責指向運行時棧的棧底。注:STACK_LEVEL() 是動態(tài)變化的,因為 stack_pointer 會動態(tài)變化。
STACK_SIZE()
運行時棧最多能存儲多少個元素,或者說運行時棧的長度。
#define STACK_SIZE() (frame->f_code->co_stacksize)
我們看到它返回了 PyCodeObject 的 co_stacksize 字段,之前介紹 PyCodeObject 對象時,我們說 co_stacksize 表示執(zhí)行代碼塊所需要的??臻g。這個描述其實會讓人感到困惑,不過當了解完運行時棧之后就清晰了,其實它表示運行時棧最多能存儲多少個元素,或者說運行時棧的長度。也可以理解為要想執(zhí)行這段代碼塊,后續(xù)創(chuàng)建棧楨時,應該給 localsplus 數(shù)組中表示運行時棧的區(qū)域分配能存儲多少個元素的內存。
比如 co_stacksize 是 1,那么表示應該給運行時棧分配能存儲 1 個元素的內存,即運行時棧的長度為 1。我們通過反編譯的方式,實際演示一下。
import dis
def some_func():
a = 1
b = 2
c = 3
# 這里我們只保留字節(jié)碼指令
dis.dis(some_func)
"""
RESUME
LOAD_CONST # 將元素壓入運行時棧,棧里的元素個數(shù)為 1
STORE_FAST # 將元素從棧頂彈出,棧里的元素個數(shù)為 0
LOAD_CONST # 將元素壓入運行時棧,棧里的元素個數(shù)為 1
STORE_FAST # 將元素從棧頂彈出,棧里的元素個數(shù)為 0
LOAD_CONST # 將元素壓入運行時棧,棧里的元素個數(shù)為 1
STORE_FAST # 將元素從棧頂彈出,棧里的元素個數(shù)為 0
RETURN_CONST
"""
# 也就是說,運行時棧只要能容納一個元素,即可執(zhí)行這段代碼
print(some_func.__code__.co_stacksize) # 1
我們再來看個例子。
import dis
def some_func():
a = 1
b = 2
c = 3
lst = [a, b, c]
dis.dis(some_func)
"""
RESUME
LOAD_CONST # 將元素壓入運行時棧,棧里的元素個數(shù)為 1
STORE_FAST # 將元素從棧頂彈出,棧里的元素個數(shù)為 0
LOAD_CONST # 將元素壓入運行時棧,棧里的元素個數(shù)為 1
STORE_FAST # 將元素從棧頂彈出,棧里的元素個數(shù)為 0
LOAD_CONST # 將元素壓入運行時棧,棧里的元素個數(shù)為 1
STORE_FAST # 將元素從棧頂彈出,棧里的元素個數(shù)為 0
LOAD_FAST # 將元素壓入運行時棧,棧里的元素個數(shù)為 1
LOAD_FAST # 將元素壓入運行時棧,棧里的元素個數(shù)為 2
LOAD_FAST # 將元素壓入運行時棧,棧里的元素個數(shù)為 3
BUILD_LIST # 將棧里的三個元素彈出,構建列表并入棧(此時元素個數(shù)為 1)
STORE_FAST # 將元素從棧頂彈出,棧里的元素個數(shù)為 0
RETURN_CONST
"""
# 不難看出,要想執(zhí)行這段代碼,運行時棧要能容納 3 個元素
print(some_func.__code__.co_stacksize) # 3
相信你現(xiàn)在應該理解 co_stacksize 的作用了,它表示運行時棧最多能容納多少個元素,也就是運行時棧的長度。以上面代碼為例,由于最多會壓入 3 個元素,所以運行時棧的長度就是 3,即最多能容納 3 個元素。并且這個長度在編譯之后就已經確定了,因為可以通過遍歷指令集靜態(tài)計算出來。
我們畫一張圖描述一下執(zhí)行上面的代碼時,運行時棧的變化過程。
圖片
整個過程應該很清晰,當然上面只是運行時棧的變化,localsplus 中存儲局部變量的內存區(qū)域也在變化。另外如果代碼塊位于全局作用域,那么變化的就是全局名字空間。
EMPTY()
#define EMPTY() (STACK_LEVEL() == 0)
判斷運行時棧是否為空,顯然只需判斷運行時棧的元素個數(shù)是否為 0 即可。
TOP()
#define TOP() (stack_pointer[-1])
查看當前運行時棧的棧頂元素。
SECOND()
#define SECOND() (stack_pointer[-2])
查看從棧頂元素開始的第二個元素。
THIRD()
#define THIRD() (stack_pointer[-3])
查看從棧頂元素開始的第三個元素。
FOURTH()
#define FOURTH() (stack_pointer[-4])
查看從棧頂元素開始的第四個元素。
PEEK(n):
#define PEEK(n) (stack_pointer[-(n)])
查看從棧頂元素開始的第 n 個元素。
SET_TOP(v)
#define SET_TOP(v) (stack_pointer[-1] = (v))
將當前運行時棧的棧頂元素設置成 v。
SET_SECOND(v)
#define SET_SECOND(v) (stack_pointer[-2] = (v))
將從棧頂元素開始的第二個元素設置成 v。
POKE(n)
#define POKE(n, v) (stack_pointer[-(n)] = (v))
將從棧頂元素開始的第 n 個元素設置成 v。
PUSH(v)
#define PUSH(v) BASIC_PUSH(v)
#define BASIC_PUSH(v) (*stack_pointer++ = (v))
往運行時棧中壓入一個元素,并且壓入之后,棧中已存儲的元素個數(shù)一定不超過 co_stacksize。換句話說,STACK_LEVEL() 永遠小于等于 STACK_SIZE()。
然后入棧和出棧,都是通過操作 stack_pointer 完成的。假設當前棧里有一個元素 1,然后添加一個元素 2。
圖片
Python 的變量都是一個指針,所以 stack_pointer 是一個二級指針,它永遠指向棧頂位置,只不過棧頂位置會變。
注:運行時棧的內存一開始就申請好了,初始狀態(tài)下,里面的元素全部為 NULL。而往棧里面壓入一個元素,其實就是修改 stack_pointer 指向的內存單元,然后執(zhí)行 stack_pointer++。
POP()
#define POP() BASIC_POP()
#define BASIC_POP() (*--stack_pointer)
彈出棧頂元素,注意它和 TOP 的區(qū)別,TOP 是返回棧頂元素,但不彈出。
圖片
stack_pointer 指向棧頂位置,所以它向棧底移動一個位置,就相當于元素被彈出了。
關于彈出元素需要做一個說明,所謂彈出元素本質上就是將 stack_pointer 向棧底移動一個位置。我們看一下上圖,一開始棧里面有兩個元素,分別是整數(shù) 1 和整數(shù) 2,當然準確來說應該是指向它們的指針,但為了描述方便,我們就用對象本身代替了。
然后執(zhí)行 POP(),將整數(shù) 2 彈出,但我們發(fā)現(xiàn) POP() 之后,整數(shù) 2 還在棧里面。關于這一點很好理解,因為 stack_pointer 始終指向棧頂位置,而它向棧底移動了一個位置,那么整數(shù) 2 就已經不是棧頂元素了。當下一個元素入棧時,會把整數(shù) 2 替換掉。因此雖然整數(shù) 2 還在運行時棧里面,但和不在沒有任何區(qū)別,此時我們依舊認為整數(shù) 2 被彈出了。
不過在后續(xù)的文章中,在畫運行時棧的時候,我們會這么畫。
圖片
為了閱讀清晰,stack_pointer 之后的元素就不寫了。另外還要注意一點,運行時棧的內存一開始就已經申請好了,是固定的,彈出元素只是改變棧頂指針 stack_pointer 的指向,內存區(qū)域的大小不變。
當然這些內容都比較簡單,但為了避免出現(xiàn)歧義,這里單獨解釋一下。
STACK_GROW(n)
#define STACK_GROW(n) BASIC_STACKADJ(n)
#define BASIC_STACKADJ(n) (stack_pointer += n)
改變運行時棧的棧頂,注:運行時棧的大小是固定的,但棧頂是由 stack_pointer 決定的。
圖片
那么問題來了,假設要往運行時棧壓入兩個元素,分別是 2、3,該怎么做呢?首先肯定可以通過 PUSH 實現(xiàn)。
PUSH(2);
PUSH(3);
但如果不讓你用 PUSH,該怎么做呢?
STACK_GROW(2);
// 設置元素
POKE(1, 3); // stack_pointer[-1] = 3
POKE(2, 2); // stack_pointer[-2] = 2
兩種做法都是可以的。
STACK_SHRINK(n)
#define STACK_SHRINK(n) BASIC_STACKADJ(-(n))
#define BASIC_STACKADJ(n) (stack_pointer += n)
它的作用和 STACK_GROWN 正好相反。
圖片
以上就是運行時棧的一些 API,后續(xù)再看源碼的時候,會經常遇到。
小結
本篇文章我們從宏觀的角度介紹了虛擬機執(zhí)行字節(jié)碼的流程,說白了虛擬機就是把自己當成一個 CPU,在棧楨中執(zhí)行指令。通過遍歷字節(jié)碼指令集,基于不同的指令執(zhí)行不同的處理邏輯。
然后是運行時棧,因為一個指令只能帶一個參數(shù),那么剩余的參數(shù)就需要通過運行時棧給出。比如 LOAD_CONST 指令,它在加載完常量之后,會將常量壓入運行時棧,然后 STORE_NAME 或 STORE_FAST 指令再將常量從運行時棧的頂部彈出,和某個符號(變量)綁定起來。另外關于這些指令,我們后面會詳細說。
最后我們介紹了運行時棧的一些 API,或者說宏,因為執(zhí)行指令的時候會反復操作運行時棧,所以底層封裝了很多的宏。