在《跨平台PHP调试器设计及使用方法——探索和设计》一文中,我介绍了将使用pydbgp作为和Xdebug的通信库,并让pydbgp以(孙)子进程的方式存在。《跨平台PHP调试器设计及使用方法——通信》解决了和pydbgp通信的问题,本文将讲解和pydbgp通信协议的问题。(转载请指明出于breaksoftware的csdn博客)
和Xdebug的通信协议不同,和pydbgp的通信协议其实就是对其调用规则和对返回结果解析的规则。这块技术并没有什么高深之处,只是pydbgp的资料很少,其规则也没有相关说明,只能靠查看源码和实践来收集和分析。我尽量以调用顺序来讲解相关协议。
首先,我们需要设置IDE Key参数。这步操作我放在start_debugger函数中
def start_debugger(self): if self._pydbgpd: return {"ret":1} self._pydbgpd = pydbgpd_stub() self._pydbgpd.start() self._pydbgpd.query('key netbeans-xdebug') def stop_debugger(self): if self._pydbgpd: self._pydbgpd.stop() del self._pydbgpd self._pydbgpd = None def is_session(self): if not self._pydbgpd: return False return self._pydbgpd.is_session()pydbpgd_stub是 《跨平台PHP调试器设计及使用方法——通信》一文介绍的父程序中的“桩”,对它的调用就如同对pydbgpd(子进程中)的调用一样,感受不到跨进程带来的各种不便。stop_debugger用于关闭调试,is_session用于判断调试器是否处在“session阶段”。这些都是通过对pydbgpd_stub对象操作实现的。之后我们所有要和调试器通信的地方都会看到它。
def start_listen(self, param): if False == self._listening: data = self._pydbgpd.query('listen -p localhost:9000 start') #ERROR: dbgp.server: the debugger could not bind on port 9000. if "ERROR" in data: return {"ret":0} self._listening = True return {"ret":1}start_listen中,我们通过上述第三行的命令启动端口监听。如果调用成功,则没有任何数据返回。如果调用失败,则会返回错误,比如待绑定的端口被占用时,会返回上述第四行的信息。我们通过返回信息中是否包含ERROR来判断该操作是否成功。
如果此时有PHP执行触发了调试,则我们需要查看有哪些调试连接已经接入
def sessions(self,param): data = self._pydbgpd.query('sessions') sessions = [] arr = data.split('\n') #data = "#2344:<dbgp.server.application instance at 0x025839E0>" for item in arr: try: if not len(item): continue res = self._sessions_info_pattern.search(item).groups() sessions.append(res[0]) except Exception,errinfo: print errinfo, "sessions error:" + data + "\n" return sessionssessions函数中,我们通过向pydbgp发送“sessions”指令来查看调试接入(会话)信息。上述第五行是一个接入信息的返回数据,如果此时有多条调试接入,则会产生多行信息。我们通过对换行符切分,并对每条数据通过正则提取,获取所有会话号。上述例子中的会话号就是2344。
def select(self,param): select_cmd = "select " + param ret = self._pydbgpd.query(select_cmd) if self.is_session(): return {"ret":1} return {"ret":0}select方法传入的是会话号,pydbgp在执行上述第二行的指令后,不会返回任何数据。此时我们可以通过is_session判断调试器是否进入session阶段,如果进入了,则证明执行成功,否则失败。
def add_breakpoint(self,breakpointinfo): breakpoint_set_type_keys = { "line" : {"filename":"-f","lineno":"-n"}, "call" : {"function":"-m"}, "return" : {"function":"-m"}, "exception" : {"exception":"-x"}, "conditional" : {"filename":"-f","lineno":"-n","expression":"-c"}, "watch" : {}, } query = "breakpoint_set -t " + breakpointinfo["type"] for (key,value) in breakpoint_set_type_keys[breakpointinfo["type"]].items(): if value == "-c": expression_de = base64.b64decode(breakpointinfo[key]) query = query + " " + value + " '" + expression_de + " '" #maybe bug if expression_de has ' else: query = query + " " + value + " " + breakpointinfo[key] data = self._pydbgpd.query(query) iteminfo = self._parse_breakpoint_info(data) if not iteminfo: ret = 0 else: ret = 1 return {"ret":ret, "breakpoint":iteminfo}以设置行号断点为例,我们最终的调用方式是breakpoint_set -t line -f file:///home/work/xxxx.php -n 10。这儿有点特别的是条件断点的设置,因为条件的内容我们无法控制,所以需要使用base64对其进行编码。pydbgp执行新增断点的请求后会返回该断点的信息(实际信息不全,这也将导致我们之后断点相关的逻辑设计的比较曲折)。
设置完断点后,我们需要查看我们设置了哪些断点。
def breakpoint_list(self, param): data = self._pydbgpd.query("breakpoint_list") #data ="""<dbgp.server.breakpoint: id:11900002 type:line filename:file:///var/www/html/index.php lineno:8 function: state:enabled exception: expression: temporary:0 hit_count:0 hit_value:None hit_condition:None> #<dbgp.server.breakpoint: id:11900003 type:line filename:file:///var/www/html/index.php lineno:9 function: state:enabled exception: expression: temporary:0 hit_count:0 hit_value:None hit_condition:None>""" info = [] arr = data.split('\n') for item in arr: if not len(item): continue iteminfo = self._parse_breakpoint_info(item) if iteminfo: info.append(iteminfo) return info第三行给出了断点的样例,我们继而调用_parse_breakpoint_info和parse_breakpoint_info方法去提取断点信息
def _parse_breakpoint_info(self, info): iteminfo = {} try: iteminfo = self.parse_breakpoint_info(info) except Exception,errinfo: print errinfo, "_parse_breakpoint_info error:" + info + "\n" return iteminfo #data = "<dbgp.server.breakpoint: id:65920004 type:conditional filename:file:///D:/nginx-1.11.3/html/index.php lineno:30 function: state:enabled exception: expression:$i ==6 temporary:0 hit_count:0 hit_value:None hit_condition:None>" def parse_breakpoint_info(self, data): breakpoint_info = {} keys = ["id","type","filename","lineno","function","state","exception","expression","temporary","hit_count","hit_value","hit_condition"] data_end = data.rfind(">") for key_index in range(0, len(keys)): search_key = " " + keys[key_index] + ":" index_start = data.find(search_key) + len(search_key) if -1 == index_start: raise debugger_exception("parse_breakpoint_info error: no keys" + keys[key_index] ) if key_index < len(keys) - 1: next_key_index = key_index + 1 search_key = " " + keys[next_key_index] + ":" index_end = data.find(search_key) if -1 == index_end: raise debugger_exception("parse_breakpoint_info error: no keys" + keys[index_end] ) else: index_end = data_end breakpoint_info[keys[key_index]] = data[index_start:index_end] return breakpoint_info上述第12行列出了断点信息的类型,它们分别是:标识号、类型、文件路径、行号(为行号断点时有效)、函数名(调用和返回断点时有效)、状态(有效还是失效)、异常类型名(异常断点时有效)、表达式、是否为临时断点(只断一次)、命中次数、命中值(猜测,实际没发现有什么数据)和命中条件。由于实际返回的数据信息不全,我们不能全以其信息为准,这块我们将在之后介绍。
有新增断点就有删除断点,删除断点比较简单,我们只要传入断点ID即可
def remove_breakpoint(self,breakpointid): query = "breakpoint_remove -d " + breakpointid data = self._pydbgpd.query(query) if "breakpoint removed" in data: ret = 1 else: ret = 0 return {"ret":ret}如果删除成功,则会返回breakpoint removed。我们通过返回值判断操作是否成功。
设置完断点后,我们需要通过“步过”、“步入”,“步出”,“执行”等操作控制程序执行,它们的执行逻辑很简单,且没有返回值
def step_over(self, param): return self._step_cmd("step over") def step_in(self, param): return self._step_cmd("step in") def step_out(self, param): return self._step_cmd("step out") def run(self, param): return self._step_cmd("run") def _step_cmd(self,cmd): if False == self._pydbgpd.is_session(): return {} data = self._pydbgpd.query(cmd) if len(data): return {"ret":0} else: return {"ret":1}如果我们执行run之后,程序被中断了,我们可以通过查看状态的命令查看断点调试器的状态
#0 out of session 1 starting 2 break 3 stopping 4 stopped 5 waiting def status(self,param): if not self._pydbgpd.is_session(): return {"ret":1, "status":0} data = self._pydbgpd.query('status') out_of_sesion_status = "invalid cmd" starting_status = "Current Status: status [starting] reason[ok]" break_status = "Current Status: status [break] reason[ok]" stopping_status = "Current Status: status [stopping] reason[ok]" stopped_status = "command sent after session stopped" waiting_status = "session timed out while waiting for response" status = -1 status_map = { out_of_sesion_status:0, starting_status:1, break_status:2, stopping_status:3, stopped_status:4, waiting_status:5 }; for (key,value) in status_map.items(): if key in data: status = value break if not len(data): status = 0 return {"ret":1,"status":status}starting状态是启动调试后的第一个状态,此时还没进入PHP代码。break状态就是被我们断点中断的状态,或者我们执行“步过”、“步入”和“步出”后的调试器状态。stopping状态是已经不在PHP代码中,但是即将结束的状态。对于一个没有断点的程序,执行了“run”之后就进入stopping状态,而中间不会经过break状态。stopped状态表示该会话已经彻底结束,我们可以退出该会话了。waiting状态在调用非常耗时的操作时会出现。
如果调试器处于break状态,则我们可以通过查看调用堆栈的方式查看程序执行路径。
def stack_get(self,param): return {"ret":1, "data":self._get_stack_info()} def _get_stack_info(self, frame = ""): if False == self._pydbgpd.is_session(): return [] query = 'stack_get ' + frame data = self._pydbgpd.query(query) #data = "frame: 0 file:///var/www/html/index.php(8) file {main}" frame_list = [] arr = data.split('\n') for item in arr: if not len(item): continue try: res = self._stack_get_pattern.search(item).groups() info = {} info['frame'] = res[0] info['filename'] = res[1] #info['path'] = info['path'].replace('/', os.sep) info['filename_last'] = info['filename'].split('/')[-1] info['lineno'] = res[2] info['function'] = res[3] m1 = md5.new() m1.update(info['filename']) info['file_id'] = m1.hexdigest() frame_list.append(info) except Exception,errinfo: print errinfo, "stack_get error:" + data + "\n" return如果我们执行stack_get,则会返回全部的调用堆栈信息。如果给stack_get传入堆栈号,则返回该调用栈的信息。一般堆栈信息包含堆栈号、所处的文件路径、所处的行号和函数名。我们在之后的UI层通过这个函数可以动态的更新代码的执行情况。
我们调试的一个重要的目的就是可以随时查看变量值,所以查看变量也是调试器的重点。通过Xdebug获取所有栈上的变量要分为三步:
def _get_all_variables(self, cur = False): all_data = self._get_stack_variables(cur) return {"ret":1, "data":all_data} def _get_stack_variables(self, cur = False): info = {} data = self._pydbgpd.query('stack_depth') #'Stack Depth: 3' pattern = re.compile("Stack Depth: (\d+)") try: res = pattern.search(data).groups() for index in range(0, int(res[0])): iteminfo = self._get_context_variables(index) key = "Frame " + str(index) info[key] = iteminfo if cur: break except Exception,errinfo: print errinfo, "_get_stack_variables error:" + data + "\n" return info def _get_context_variables(self, depth_id): data = self._pydbgpd.query('context_names') #data='''0: Locals #1: Superglobals #2: User defined constants''' info = {} arr = data.split('\n') for item in arr: if not len(item): continue try: res = self._context_names_pattern.search(item).groups() iteminfo = self._get_context(depth_id, res[0]) info[res[1]] = iteminfo except Exception,errinfo: print errinfo, "context_names error:" + item + "\n" return info def _get_context(self, depth_id, context_id): query = 'context_get -d ' + str(depth_id) + ' -c ' + str(context_id) data = self._pydbgpd.query(query) #data = '''name: $a type: string value: 123 #name: $b type: int value: 234''' info = [] arr = data.split('\n') for item in arr: if not len(item): continue try: res = self._context_get_pattern.search(item).groups() iteminfo = {} iteminfo["name"] = res[0] iteminfo["type"] = res[1] iteminfo["value"] = res[2] info.append(iteminfo) except Exception,errinfo: print errinfo, "context_get error:" + item + "\n" return infocontext_names可能用户不大理解,其实它就是变量类型。比如全局变量里我们可以看到Http请求的相关信息。这步操作相对于其他操作需要多次查询和解析,所以它的效率是非常低的。所以我在设计时没有让其自动更新(除非用户选择的展现页为变量页,这样每步操作都要更新变量),也没让变量对比功能自动开启。
如果调试会话结束,我们可以通过下面的方法退出调试
def quit(self,param): return self._step_cmd("quit") def stop(self,param): return self._step_cmd("stop") def exit(self,param): return self._step_cmd("exit")这样主流的一些操作我们讲解完了,我们再讲解些不太能用到的。比如查看当前执行到的代码上下文,可以使用source命令
def source(self,param): src = self._pydbgpd.query("source") if "(u'stack depth invalid', 301)" in src: return {"ret": 0} return {"ret": 1, "data": src}
比如我们在break的情况下,需要修改某个变量值,则可以使用eval指令进行代码执行,其实这块功能非常重要
def eval(self, param): query = "eval " + param data = self._pydbgpd.query(query) return {"ret":1}我还开放了命令行式的调试方式,这样用户就可以自己输入调试命令进行调试,这个和dbg很像,于是我要做的就是命令的传导
def query(self, cmd): return self._pydbgpd.query(cmd)有了上述的方法,我们可以构建一个简单的调试器。但是由于pydbgp断点信息返回不全,而且我们需要一些高阶功能,比如调试器状态机、预设断点等,使得更高一层的封装整合成为必需。下一博文我们将重点介绍高阶封装相关的内容。