原創 鹿尤 淘系技術 6月18日

經常使用Python的同學一定熟悉pdb子產品,它是Python官方标準庫提供的互動式代碼調試器,和任何一門語言提供的調試能力一樣,pdb提供了源代碼行級别的設定斷點、單步執行等正常調試能力,是Python開發的一個很重要的工具子產品。
pdb使用方法見官方文檔,本文重點分析官方pdb子產品源碼,介紹調試功能的實作原理。
原理
從cPython源碼中可以看到,pdb子產品并非c實作的内置子產品,而是純Python實作和封裝的子產品。核心檔案是pdb.py,它繼承自bdb和cmd子產品:
class Pdb(bdb.Bdb, cmd.Cmd):
...
基本原理:利用cmd子產品定義和實作一系列的調試指令的互動式輸入,基于sys.settrace插樁跟蹤代碼運作的棧幀,針對不同的調試指令控制代碼的運作和斷點狀态,并向控制台輸出對應的資訊。
cmd子產品主要是提供一個控制台的指令互動能力,通過raw_input/readline這些阻塞的方法實作輸入等待,然後将指令交給子類處理決定是否繼續循環輸入下去,就和他主要的方法名runloop一樣。
cmd是一個常用的子產品,并非為pdb專門設計的,pdb使用了cmd的架構進而實作了互動式自定義調試。
bdb提供了調試的核心架構,依賴sys.settrace進行代碼的單步運作跟蹤,然後分發對應的事件(call/line/return/exception)交給子類(pdb)處理。bdb的核心邏輯在對于調試指令的中斷控制,比如輸入一個單步運作的”s“指令,決定是否需要繼續跟蹤運作還是中斷等待互動輸入,中斷到哪一幀等。
基本流程
- pdb啟動,目前frame綁定跟蹤函數trace_dispatch
def trace_dispatch(self, frame, event, arg):
if self.quitting:
return # None
if event == 'line':
return self.dispatch_line(frame)
if event == 'call':
return self.dispatch_call(frame, arg)
if event == 'return':
return self.dispatch_return(frame, arg)
if event == 'exception':
...
- 每一幀的不同僚件的處理都會經過中斷控制邏輯,主要是stop_here(line事件還會經過break_here)函數,處理後決定代碼是否中斷,需要中斷到哪一行。
- 如需要中斷,觸發子類方法user_#event,子類通過interaction實作棧幀資訊更新,并在控制台列印對應的資訊,然後執行cmdloop讓控制台處于等待互動輸入。
def interaction(self, frame, traceback):
self.setup(frame, traceback) # 目前棧、frame、local vars
self.print_stack_entry(self.stack[self.curindex])
self.cmdloop()
self.forget()
- 使用者輸入調試指令如“next”并回車,首先會調用set_#指令,對stopframe、returnframe、stoplineno進行設定,它會影響中斷控制```stop_here``的邏輯,進而決定運作到下一幀的中斷結果。
def _set_stopinfo(self, stopframe, returnframe, stoplineno=0):
self.stopframe = stopframe
self.returnframe = returnframe
self.quitting = 0
# stoplineno >= 0 means: stop at line >= the stoplineno
# stoplineno -1 means: don't stop at all
self.stoplineno = stoplineno
對于調試過程控制類的指令,一般do_#指令都會傳回1,這樣本次runloop立馬結束,下次運作到某一幀觸發中斷會再次啟動runloop(見第三點);對于資訊擷取類的指令,do_#指令都沒有傳回值,保持目前的中斷狀态。
- 代碼運作到下一幀,重複第三點。
中斷控制
中斷控制也就是對于不同的調試指令輸入後,能讓代碼執行到正确的位置停止,等待使用者輸入,比如輸入”s”控制台就應該在下一個運作frame的代碼處停止,而輸出“c”就需要運作到下一個打斷點的地方。
中斷控制發生在sys.settrace的每一步跟蹤的中,是調試運作的核心邏輯。
pdb中主要跟蹤了frame的四個事件:
- line:同一個frame中的順序執行事件
- call:發生函數調用,跳到下一級的frame中,在函數第一行産生call事件
- return:函數執行完最後一行(line),發生結果傳回,即将跳出目前frame回到上一級frame,在函數最後一行産生return事件
- exception:函數執行中發生異常,在異常行産生exception事件,然後在該行傳回(return事件),接下來一級一級向上在frame中産生exception和return事件,直到回到底層frame。
它們是代碼跟蹤時的不同節點類型,pdb根據使用者輸入的調試指令,在每一步frame跟蹤時都會進行中斷控制,決定接下來是否中斷,中斷到哪一行。中斷控制的主要方法是stop_here:
def stop_here(self, frame):
# (CT) stopframe may now also be None, see dispatch_call.
# (CT) the former test for None is therefore removed from here.
if self.skip and \
self.is_skipped_module(frame.f_globals.get('__name__')):
return False
# next
if frame is self.stopframe:
# stoplineno >= 0 means: stop at line >= the stoplineno
# stoplineno -1 means: don't stop at all
if self.stoplineno == -1:
return False
return frame.f_lineno >= self.stoplineno
# step:目前隻要追溯到botframe,就等待執行。
while frame is not None and frame is not self.stopframe:
if frame is self.botframe:
return True
frame = frame.f_back
return False
調試指令大體上分兩類:
- 過程控制:如setp、next、continue等這些執行後馬上進入下階段的代碼執行
- 資訊擷取/設定:如args、p、list等擷取目前資訊的,也不會影響cmd狀态
以下重點講解幾個最常見的用于過程控制的調試指令的中斷控制實作原理:
▐ s(step)
- 指令定義
執行下一條指令,如果本句是函數調用,則 s 會執行到函數的第一句。
- 代碼分析
pdb中實作邏輯為順序執行每一個幀frame并等待執行,它的執行粒度和settrace一樣。
def stop_here(self, frame):
...
# stopframe為None
if frame is self.stopframe:
...
# 目前frame一定會追溯到botframe,傳回true
while frame is not None and frame is not self.stopframe:
if frame is self.botframe:
return True
frame = frame.f_back
return False
step會将stopframe設定為None,是以隻要目前frame能向後一直追溯到底層frame(botframe),就表示可以等待執行了,也就是pdb處于互動等待狀态。
因為step的執行粒度和settrace一樣,是以運作到每一幀都會等待執行。
▐ n(next)
執行下一條語句,如果本句是函數調用,則執行函數,接着執行目前執行語句的下一條。
pdb中實作邏輯為,運作至目前frame的下一次跟蹤中斷,但進入到下一個frame(函數調用)中不會中斷。
def stop_here(self, frame):
...
# 如果frame還沒跳出stopframe,永遠傳回true
if frame is self.stopframe:
if self.stoplineno == -1:
return False
return frame.f_lineno >= self.stoplineno
# 如果frame跳出了stopframe,進入下一個frame,則執行不會中斷,一直到跳出到stopframe
# 還有一種情況,如果在return事件中斷執行了next,下一次跟蹤在上一級frame中,此時上一級frame能跟蹤到botframe,中斷
while frame is not None and frame is not self.stopframe:
if frame is self.botframe:
return True
frame = frame.f_back
return False
next會設定stopframe為目前frame,也就是除非在目前frame内,進入其他的frame都不會執行中斷。
▐ c
繼續執行,直到遇到下一條斷點
stopframe設定為botframe,stoplineno設定為-1。stop_here總傳回false,運作不會中斷,直到遇到斷點(break_here條件成立)
def stop_here(self, frame):
...
# 如果在botframe中,stoplineno為-1傳回false
if frame is self.stopframe:
if self.stoplineno == -1:
return False
return frame.f_lineno >= self.stoplineno
# 如果在非botframe中,會先追溯到stopframe,傳回false
while frame is not None and frame is not self.stopframe:
if frame is self.botframe:
return True
frame = frame.f_back
return False
▐ r(return)
執行目前運作函數到結束。
return指令僅在執行到frame結束(函數調用)時中斷,也就是遇到return事件時中斷。
pdb會設定stopframe為上一幀frame,returnframe為目前frame。如果是非return事件,stop_here永遠傳回false,不會中斷;
def stop_here(self, frame):
...
# 會先追溯到stopframe,傳回false
while frame is not None and frame is not self.stopframe:
if frame is self.botframe:
return True
frame = frame.f_back
return False
如果是return事件,stop_here仍然傳回false,但是returnframe為目前frame判斷成立,會執行中斷。
def dispatch_return(self, frame, arg):
if self.stop_here(frame) or frame == self.returnframe:
self.user_return(frame, arg)
if self.quitting: raise BdbQuit
return self.trace_dispatch
▐ unt(until)
執行到下一行,和next的差別就在于for循環隻會跟蹤一次
設定stopframe和returnframe為目前frame,stoplineno為目前lineno+1。
def stop_here(self, frame):
...
# 如果目前幀代碼順序執行,下一個frame的lineno==stoplineno
# 如果執行到for循環的最後一行,下一個frame(for循環第一行)的lineno<stoplineno,不會中斷。直到for循環執行結束,緊接着的下一行的lineno==stoplineno,執行中斷
if frame is self.stopframe:
if self.stoplineno == -1:
return False
return frame.f_lineno >= self.stoplineno
# 如果在非botframe中,會先追溯到stopframe,傳回false,同next
while frame is not None and frame is not self.stopframe:
if frame is self.botframe:
return True
frame = frame.f_back
return False
如果在目前frame中有for循環,隻會從上向下執行一次。
如果是函數傳回return事件,下一個frame的lineno有可能小于stoplineno,是以把returnframe設定為目前frame,這樣函數執行就和next表現一樣了。
▐ u(up)/d(down)
切換到上/下一個棧幀
棧幀資訊
棧幀包含代碼調用路徑上的每一級frame資訊,每次指令執行中斷都會重新整理,可以通過u/d指令上下切換frame。
棧幀擷取主要通過get_stack方法,第一個參數是frame,第二個參數是traceback object。
traceback object是在exception事件産生的,exception事件會帶一個arg參數:
exc_type, exc_value, exc_traceback = arg
(<type 'exceptions.IOError'>, (2, 'No such file or directory', 'wdwrg'), <traceback object at 0x10bd08a70>)
traceback object有幾個常用的屬性:
- tb_frame:目前exception發生在的frame
- tb_lineno:目前exception發生在的frame的行号,即frame.tb_lineno
- tb_next:指向堆棧下一級調用的exc_traceback(traceback object),如果是最頂層則為None
棧幀資訊由兩部分組成,frame的調用棧和異常棧(如有),順序為:botframe -> frame1 -> frame2 -> tb1 -> tb2(出錯tb)
def get_stack(self, f, t):
stack = []
if t and t.tb_frame is f:
t = t.tb_next
# frame調用棧,從底到頂
while f is not None:
stack.append((f, f.f_lineno))
if f is self.botframe:
break
f = f.f_back
stack.reverse()
i = max(0, len(stack) - 1)
# 異常棧,從底到頂(出錯棧)
while t is not None:
stack.append((t.tb_frame, t.tb_lineno))
t = t.tb_next
if f is None:
i = max(0, len(stack) - 1)
return stack, i
pdb每次執行中斷都會更新調用的棧幀表,以及目前的棧幀資訊,堆棧切換隻要向上/下切換索引即可。
def setup(self, f, t):
self.forget()
self.stack, self.curindex = self.get_stack(f, t)
self.curframe_locals = self.curframe.f_locals
...
...
def do_up(self, arg):
if self.curindex == 0:
print >>self.stdout, '*** Oldest frame'
else:
self.curindex = self.curindex - 1
self.curframe = self.stack[self.curindex][0]
self.curframe_locals = self.curframe.f_locals
self.print_stack_entry(self.stack[self.curindex])
self.lineno = None
▐ b(break)
差別于過程控制的調試指令,break指令用來設定斷點,不會馬上影響程式中斷狀态,但可能會影響後續的中斷。
在line事件發生的時候,除了stop_here會增加break_here的條件判斷,設定斷點的實作比較簡單,這裡主要介紹對函數設定斷點的時候,是怎麼讓代碼執行到函數第一行中斷的。
設定斷點時,斷點的lineno為了函數的第一行:
# 函數斷點示例:break func
def do_break(self, arg, temporary = 0):
...
if hasattr(func, 'im_func'):
func = func.im_func
funcname = code.co_name
lineno = code.co_firstlineno
filename = code.co_filename
當line事件執行到函數的第一行代碼時,這一行沒有主動設定過斷點,但是函數第一行co_firstlineno命中斷點,是以會繼續判斷斷點有效性。
def break_here(self, frame):
...
lineno = frame.f_lineno
if not lineno in self.breaks[filename]:
lineno = frame.f_code.co_firstlineno
if not lineno in self.breaks[filename]:
return False
# flag says ok to delete temp. bp
(bp, flag) = effective(filename, lineno, frame)
斷點的有效性判斷通過effective方法,其中處理了ignore、enabled這些配置,對函數斷點的有效性判斷通過checkfuncname方法:
def checkfuncname(b, frame):
"""Check whether we should break here because of `b.funcname`."""
...
# Breakpoint set via function name.
...
# We are in the right frame.
if not b.func_first_executable_line:
# The function is entered for the 1st time.
b.func_first_executable_line = frame.f_lineno
if b.func_first_executable_line != frame.f_lineno:
# But we are not at the first line number: don't break.
return False
return True
在line事件在函數第一行發生時,func_first_executable_line還沒有,于是設定為目前行号,并且斷點生效,是以函數執行到第一行中斷。
接下來line到行數的後面行時,因為func_first_executable_line已經有值,并且肯定不等于目前行号,是以break_here判斷為無效,不會中斷。
執行個體分析
以下結合一個很簡單的Python代碼調試的例子,複習下上述指令的實作原理:
在控制台中,指令行執行快照:
指令行中執行Python test.py,Python代碼實際是從第一行開始執行的,但因為pdb.set_trace()是在__main__中調用的,是以實際是從set_trace的下一行才挂載到pdb的跟蹤函數,開始frame的中斷控制。
這段Python代碼執行會經過經過3個frame:
- 底層根frame0,即_main_所在的frame0,其中包含一斷for循環代碼,frame0的back frame為None
- 第二層frame1,進入func方法所在的frame1,frame1的back frame為frame0
- 頂層frame2,進入add方法所在的frame2,frame2的back frame為frame1
調試過程:
- 跟蹤_main_所在的frame(根frame0),在20行觸發line事件
- 使用者輸入unt指令回車,frame0在21行觸發line事件,行号等于上一次跟蹤行号+1,stop_here成立,中斷等待
- 使用者輸入unt指令回車,同2,在22行中斷
- 使用者輸入unt指令回車,代碼跟蹤至frame0在20行觸發line事件,行号小于上一次跟蹤行号+1(23),stop_here不成立,繼續執行
- 在24行觸發line事件,行号大于上一次跟蹤行号+1(23),stop_here成立,中斷等待
- 使用者輸入s指令回車,代碼跟蹤至frame1在12行觸發call事件,step執行粒度和sys.settrace一樣,在12行中斷等待
- 使用者設定add函數斷點,斷點清單中會加入add函數的第一行(第7行)的斷點
- 使用者輸入c指令回車,stop_here總傳回false,繼續跟蹤運作直到在第8行觸發line事件,雖然第8行不再斷點清單中,但目前函數幀firstlineno在,并且有效,是以在第8行中斷等待
- 使用者輸入r指令回車,後面的line事件進行中stop_here都傳回false,直到在第10行觸發return事件,此時returnframe為目前frame,在10行中斷等待
- 使用者輸入up指令,棧幀向前切換索引,回到上一幀frame1,也就是第13行func中調用add的地方
- 使用者輸入down指令,棧幀向前後切換索引,回到目前幀
- 使用者輸入n指令,運作至下一次跟蹤14行(line事件),這一次跟蹤在frame1上,能追溯到botframe,是以在14行中斷
- 使用者輸入n指令,運作至下一次跟蹤14行(return事件),還在目前frame1中,中斷
- 使用者輸入n指令,運作至下一次跟蹤24行(return事件),這一次跟蹤就是botframe(frame0),中斷
- 使用者輸入n指令,frame0執行結束。
小結
Python标準庫提供的pdb的實作并不複雜,本文對源碼中的核心的邏輯做了講解,如果你了解其原理,也可以自己定制或重寫一個Python調試器。
事實上,業界的很多通用IDE如pycharm、vscode等都沒有使用标準的pdb,他們開發了自己的Python調試器來更好的适配IDE。
不過了解pdb原理,在pdb上改寫和定制調試器來滿足調試需求,也是一種成本低而有效的方式。