深入理解python虛擬機器:偵錯程式實現原理與原始碼分析

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

深入理解python虛擬機器:偵錯程式實現原理與原始碼分析

偵錯程式是一個程式語言非常重要的部分,偵錯程式是一種用於診斷和修復程式碼錯誤(或稱為 bug)的工具,它允許開發者在程式執行時逐步檢視和分析程式碼的狀態和行為,它可以幫助開發者診斷和修復程式碼錯誤,理解程式的行為,最佳化效能。無論在哪種程式語言中,偵錯程式都是一個強大的工具,對於提高開發效率和程式碼質量都起著積極的作用。

在本篇文章當中主要給大家介紹 python 語言當中偵錯程式的實現原理,透過了解一個語言的偵錯程式的實現原理我們可以更加深入的理解整個語言的執行機制,可以幫助我們更好的理解程式的執行。

讓程式停下來

如果我們需要對一個程式進行除錯最重要的一個點就是如果讓程式停下來,只有讓程式的執行停下來我們才能夠觀察程式執行的狀態,比如我們需要除錯 99 乘法表:

def m99():
    for i in range(1, 10):
        for j in range(1, i + 1):
            print(f"{i}x{j}={i*j}", end='\t')
        print()


if __name__ == '__main__':
    m99()

現在執行命令 python -m pdb pdbusage.py 就可以對上面的程式進行除錯:

(py3.8) ➜  pdb_test git:(master) ✗ python -m pdb pdbusage.py
> /Users/xxxx/Desktop/workdir/dive-into-cpython/code/pdb_test/pdbusage.py(3)<module>()
-> def m99():
(Pdb) s
> /Users/xxxx/Desktop/workdir/dive-into-cpython/code/pdb_test/pdbusage.py(10)<module>()
-> if __name__ == '__main__':
(Pdb) s
> /Users/xxxx/Desktop/workdir/dive-into-cpython/code/pdb_test/pdbusage.py(11)<module>()
-> m99()
(Pdb) s
--Call--
> /Users/xxxx/Desktop/workdir/dive-into-cpython/code/pdb_test/pdbusage.py(3)m99()
-> def m99():
(Pdb) s
> /Users/xxxx/Desktop/workdir/dive-into-cpython/code/pdb_test/pdbusage.py(4)m99()
-> for i in range(1, 10):
(Pdb) s
> /Users/xxxx/Desktop/workdir/dive-into-cpython/code/pdb_test/pdbusage.py(5)m99()
-> for j in range(1, i + 1):
(Pdb) s
> /Users/xxxx/Desktop/workdir/dive-into-cpython/code/pdb_test/pdbusage.py(6)m99()
-> print(f"{i}x{j}={i*j}", end='\t')
(Pdb) p i
1
(Pdb) 

當然你也可以在 IDE 當中進行除錯:

根據我們的除錯經歷容易知道,要想除錯一個程式首先最重要的一點就是程式需要在我們設定斷點的位置要能夠停下來

cpython 王炸機制 —— tracing

現在的問題是,上面的程式是怎麼在程式執行時停下來的呢?

根據前面的學習我們可以瞭解到,一個 python 程式的執行首先需要經過 python 編譯器編譯成 python 位元組碼,然後交給 python 虛擬機器進行執行,如果需要程式停下來就一定需要虛擬機器給上層的 python 程式提供介面,讓程式在執行的時候可以知道現在執行到什麼位置了。這個神秘的機制就隱藏在 sys 這個模組當中,事實上這個模組幾乎承擔了所有我們與 python 直譯器互動的介面。實現偵錯程式一個非常重要的函式就是 sys.settrace 函式,這個函式將為執行緒設定一個追蹤函式,當虛擬機器有函式呼叫,執行完一行程式碼的時候、甚至執行完一條位元組碼之後就會執行這個函式。

設定系統的跟蹤函式,允許在 Python 中實現一個 Python 原始碼偵錯程式。該函式是執行緒特定的;為了支援多執行緒除錯,必須對每個正在除錯的執行緒註冊一個跟蹤函式,使用 settrace() 或者使用 threading.settrace() 。

跟蹤函式應該有三個引數:frame、event 和 arg。frame 是當前的棧幀。event 是一個字串:'call'、'line'、'return'、'exception'、 'opcode' 、'c_call' 或者 'c_exception'。arg 取決於事件型別。

跟蹤函式在每次進入新的區域性作用域時被呼叫(事件設定為'call');它應該返回一個引用,用於新作用域的本地跟蹤函式,或者如果不想在該作用域中進行跟蹤,則返回None。

如果在跟蹤函式中發生任何錯誤,它將被取消設定,就像呼叫settrace(None)一樣。

事件的含義如下:

  • call,呼叫了一個函式(或者進入了其他程式碼塊)。呼叫全域性跟蹤函式;arg 為 None;返回值指定了本地跟蹤函式。

  • line,將要執行一行新的程式碼,引數 arg 的值為 None 。

  • return,函式(或其他程式碼塊)即將返回。呼叫本地跟蹤函式;arg 是將要返回的值,如果事件是由引發的異常引起的,則arg為None。跟蹤函式的返回值將被忽略。

  • exception,發生了異常。呼叫本地跟蹤函式;arg是一個元組(exception,value,traceback);返回值指定了新的本地跟蹤函式。

  • opcode,直譯器即將執行新的位元組碼指令。呼叫本地跟蹤函式;arg 為 None;返回值指定了新的本地跟蹤函式。預設情況下,不會發出每個操作碼的事件:必須透過在幀上設定 f_trace_opcodes 為 True 來顯式請求。

  • c_call,一個 c 函式將要被呼叫。

  • c_exception,呼叫 c 函式的時候產生了異常。

自己動手實現一個簡單的偵錯程式

在本小節當中我們將實現一個非常簡單的偵錯程式幫助大家理解偵錯程式的實現原理。偵錯程式的實現程式碼如下所示,只有短短几十行卻可以幫助我們深入去理解偵錯程式的原理,我們先看一下實現的效果在後文當中再去分析具體的實現:

import sys

file = sys.argv[1]
with open(file, "r+") as fp:
    code = fp.read()
lines = code.split("\n")


def do_line(frame, event, arg):
    print("debugging line:", lines[frame.f_lineno - 1])
    return debug


def debug(frame, event, arg):
    if event == "line":
        while True:
            _ = input("(Pdb)")
            if _ == 'n':
                return do_line(frame, event, arg)
            elif _.startswith('p'):
                _, v = _.split()
                v = eval(v, frame.f_globals, frame.f_locals)
                print(v)
            elif _ == 'q':
                sys.exit(0)
    return debug


if __name__ == '__main__':
    sys.settrace(debug)
    exec(code, None, None)
    sys.settrace(None)

在上面的程式當中使用如下:

  • 輸入 n 執行一行程式碼。
  • p name 列印變數 name 。
  • q 退出除錯。

現在我們執行上面的程式,進行程式除錯:

(py3.10) ➜  pdb_test git:(master) ✗ python mydebugger.py pdbusage.py
(Pdb)n
debugging line: def m99():
(Pdb)n
debugging line: if __name__ == '__main__':
(Pdb)n
debugging line:     m99()
(Pdb)n
debugging line:     for i in range(1, 10):
(Pdb)n
debugging line:         for j in range(1, i + 1):
(Pdb)n
debugging line:             print(f"{i}x{j}={i*j}", end='\t')
1x1=1   (Pdb)n
debugging line:         for j in range(1, i + 1):
(Pdb)p i
1
(Pdb)p j
1
(Pdb)q
(py3.10) ➜  pdb_test git:(master) ✗ 

可以看到我們的程式真正的被除錯起來了。

現在我們來分析一下我們自己實現的簡易版本的偵錯程式,在前文當中我們已經提到了 sys.settrace 函式,呼叫這個函式時需要傳遞一個函式作為引數,被傳入的函式需要接受三個引數:

  • frame,當前正在執行的棧幀。

  • event,事件的類別,這一點在前面的檔案當中已經提到了。

  • arg,引數這一點在前面也已經提到了。

  • 同時需要注意的是這個函式也需要有一個返回值,python 虛擬機器在下一次事件發生的時候會呼叫返回的這個函式,如果返回 None 那麼就不會在發生事件的時候呼叫 tracing 函式了,這是程式碼當中為什麼在 debug 返回 debug 的原因。

我們只對 line 這個事件進行處理,然後進行死迴圈,只有輸入 n 指令的時候才會執行下一行,然後列印正在執行的行,這個時候就會退出函式 debug ,程式就會繼續執行了。python 內建的 eval 函式可以獲取變數的值。

python 官方偵錯程式原始碼分析

python 官方的偵錯程式為 pdb 這個是 python 標準庫自帶的,我們可以透過 python -m pdb xx.py 去除錯檔案 xx.py 。這裡我們只分析核心程式碼:

程式碼位置:bdp.py 下面的 Bdb 類

    def run(self, cmd, globals=None, locals=None):
        """Debug a statement executed via the exec() function.

        globals defaults to __main__.dict; locals defaults to globals.
        """
        if globals is None:
            import __main__
            globals = __main__.__dict__
        if locals is None:
            locals = globals
        self.reset()
        if isinstance(cmd, str):
            cmd = compile(cmd, "<string>", "exec")
        sys.settrace(self.trace_dispatch)
        try:
            exec(cmd, globals, locals)
        except BdbQuit:
            pass
        finally:
            self.quitting = True
            sys.settrace(None)

上面的函式主要是使用 sys.settrace 函式進行 tracing 操作,當有事件發生的時候就能夠捕捉了。在上面的程式碼當中 tracing 函式為 self.trace_dispatch 我們再來看這個函式的程式碼:

    def trace_dispatch(self, frame, event, arg):
        """Dispatch a trace function for debugged frames based on the event.

        This function is installed as the trace function for debugged
        frames. Its return value is the new trace function, which is
        usually itself. The default implementation decides how to
        dispatch a frame, depending on the type of event (passed in as a
        string) that is about to be executed.

        The event can be one of the following:
            line: A new line of code is going to be executed.
            call: A function is about to be called or another code block
                  is entered.
            return: A function or other code block is about to return.
            exception: An exception has occurred.
            c_call: A C function is about to be called.
            c_return: A C function has returned.
            c_exception: A C function has raised an exception.

        For the Python events, specialized functions (see the dispatch_*()
        methods) are called.  For the C events, no action is taken.

        The arg parameter depends on the previous event.
        """
        if self.quitting:
            return # None
        if event == 'line':
            print("In line")
            return self.dispatch_line(frame)
        if event == 'call':
            print("In call")
            return self.dispatch_call(frame, arg)
        if event == 'return':
            print("In return")
            return self.dispatch_return(frame, arg)
        if event == 'exception':
            print("In execption")
            return self.dispatch_exception(frame, arg)
        if event == 'c_call':
            print("In c_call")
            return self.trace_dispatch
        if event == 'c_exception':
            print("In c_exception")
            return self.trace_dispatch
        if event == 'c_return':
            print("In c_return")
            return self.trace_dispatch
        print('bdb.Bdb.dispatch: unknown debugging event:', repr(event))
        return self.trace_dispatch

從上面的程式碼當中可以看到每一種事件都有一個對應的處理函式,在本文當中我們主要分析 函式 dispatch_line,這個處理 line 事件的函式。

    def dispatch_line(self, frame):
        """Invoke user function and return trace function for line event.

        If the debugger stops on the current line, invoke
        self.user_line(). Raise BdbQuit if self.quitting is set.
        Return self.trace_dispatch to continue tracing in this scope.
        """
        if self.stop_here(frame) or self.break_here(frame):
            self.user_line(frame)
            if self.quitting: raise BdbQuit
        return self.trace_dispatch

這個函式首先會判斷是否需要在當前行停下來,如果需要停下來就需要進入 user_line 這個函式,後面的呼叫鏈函式比較長,我們直接看最後執行的函式,根據我們使用 pdb 的經驗來看,最終肯定是一個 while 迴圈讓我們可以不斷的輸入指令進行處理:

    def cmdloop(self, intro=None):
        """Repeatedly issue a prompt, accept input, parse an initial prefix
        off the received input, and dispatch to action methods, passing them
        the remainder of the line as argument.

        """
        print("In cmdloop")
        self.preloop()
        if self.use_rawinput and self.completekey:
            try:
                import readline
                self.old_completer = readline.get_completer()
                readline.set_completer(self.complete)
                readline.parse_and_bind(self.completekey+": complete")
            except ImportError:
                pass
        try:
            if intro is not None:
                self.intro = intro
            print(f"{self.intro = }")
            if self.intro:
                self.stdout.write(str(self.intro)+"\n")
            stop = None
            while not stop:
                print(f"{self.cmdqueue = }")
                if self.cmdqueue:
                    line = self.cmdqueue.pop(0)
                else:
                    print(f"{self.prompt = } {self.use_rawinput}")
                    if self.use_rawinput:
                        try:
                            # 核心邏輯就在這裡 不斷的要求輸入然後進行處理
                            line = input(self.prompt) # self.prompt = '(Pdb)'
                        except EOFError:
                            line = 'EOF'
                    else:
                        self.stdout.write(self.prompt)
                        self.stdout.flush()
                        line = self.stdin.readline()
                        if not len(line):
                            line = 'EOF'
                        else:
                            line = line.rstrip('\r\n')

                line = self.precmd(line)
                stop = self.onecmd(line) # 這個函式就是處理我們輸入的字串的比如 p n 等等
                stop = self.postcmd(stop, line)
            self.postloop()
        finally:
            if self.use_rawinput and self.completekey:
                try:
                    import readline
                    readline.set_completer(self.old_completer)
                except ImportError:
                    pass
    def onecmd(self, line):
        """Interpret the argument as though it had been typed in response
        to the prompt.

        This may be overridden, but should not normally need to be;
        see the precmd() and postcmd() methods for useful execution hooks.
        The return value is a flag indicating whether interpretation of
        commands by the interpreter should stop.

        """
        cmd, arg, line = self.parseline(line)
        if not line:
            return self.emptyline()
        if cmd is None:
            return self.default(line)
        self.lastcmd = line
        if line == 'EOF' :
            self.lastcmd = ''
        if cmd == '':
            return self.default(line)
        else:
            try:
                # 根據下面的程式碼可以分析瞭解到如果我們執行命令 p 執行的函式為 do_p
                func = getattr(self, 'do_' + cmd)
            except AttributeError:
                return self.default(line)
            return func(arg)

現在我們再來看一下 do_p 列印一個表示式是如何實現的:

    def do_p(self, arg):
        """p expression
        Print the value of the expression.
        """
        self._msg_val_func(arg, repr)

    def _msg_val_func(self, arg, func):
        try:
            val = self._getval(arg)
        except:
            return  # _getval() has displayed the error
        try:
            self.message(func(val))
        except:
            self._error_exc()

    def _getval(self, arg):
        try:
            # 看到這裡就破案了這不是和我們自己實現的 pdb 獲取變數的方式一樣嘛 都是
            # 使用當前執行棧幀的全域性和區域性變數交給 eval 函式處理 並且將它的返回值輸出
            return eval(arg, self.curframe.f_globals, self.curframe_locals)
        except:
            self._error_exc()
            raise

總結

在本篇文章當中我們主要分析 python 當中實現偵錯程式的原理,並且透過一個幾十行的程式碼實現了一個非常簡單的偵錯程式,這可以深入幫助我們理解偵錯程式實現的細節,這讓我們對於程式設計語言的認識又加深了一點。最後簡單的介紹了一下 python 自己的偵錯程式 pdb,但是有一點遺憾的目前 pdb 還不能夠支援直接除錯 python 位元組碼,但是在 python 虛擬機器當中已經有除錯位元組碼的事件了,相信在未來應該可以直接除錯位元組碼了。

還記得我們在討論 frameobject 的時候有一個欄位 f_trace 嘛,這個欄位就是指向我們傳遞給 sys.settrace 的函式,當發生事件的時候虛擬機器就會呼叫這個函式。


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

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

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

相關文章