[TOC]
Debug是主流语言一般都会提供的能力,方便开发者在遇到问题时跟踪当前调用堆栈和变量的情况。但是对于其中的原理我相信大部分同学是没有研究过的。最近在做一个Javascript的debug工具,对debug相关的知识有了接触,这里做一个总结和大家交流下
原理概述
deubg架构图:利用adb forward将PC端口9420转发到手机端口9420实现双方socket通信
image.png
debug时序图:
image.png
如上图,Debug分为工具端和程序端,工具端是展示源码和运行堆栈的ide或者编辑器;程序端就是我们运行中的要debug的程序。
工具端通过socket给程序端发送attach setbreakpoint等指令,程序端有一个read线程专门接收相关指令,最终将相关指令执行或者发送到其他线程执行。
Socket通信
利用通用的sys/socket.h提供的socket接口进行读写通信。
这里定了一个简单的协议:
1.头四个字节存储指令长度,后面拼接指令内容;
2.指令body用json来承载
{
"id": 1,
"domain": "Debugger",
"commands": {
"name": "SetBreakpointByUrl",
"parameters": [{
"proj_dir":"blabla-game",
"lineNumber": 73,
"url": "BoxRotate.js"
}]
}
}
Deubg相关接口
一个语言之所以能够debug肯定是他的运行环境对外提供了相关接口,让我们有机会再它运行的时候进行拦截。Js的解析引擎是Javascript Core(简称JSC),它就提供了相关的debug接口,下面我们来详细看下这些接口。
首先这些dubug接口都是在JSC的私有头文件并没有对外暴露,所以我们想使用这些接口,就必须将这些私有的头文件include进来。但是呢每个版本的私有头文件是有变动的,所以我们必要要保证使用的头文件和依赖的JSC.so是同一个版本。
最终我们include这些头文件:
JSC和debug相关的接口位于debugger目录的Debugger.h
初始化
这里的接口用起来也很方便,只需要实例化一个Debugger,再调用起attach方法即可。
Debugger(VM&);
void attach(JSGlobalObject*);
JSGlobalContextRef ctx = reinterpret_cast<JSGlobalContextRef>(jsGlobalContext);
JSC::JSGlobalObject *pGlobalObject = toJS(ctx)->vmEntryGlobalObject();
jsGlobalObject.vm()
我们可以看到这里需要一个JSGlobalObject,这个获取也很简单,就是JS虚拟机初始化的时候创建的context转换而来:
#include <cassert>
#include <runtime/JSGlobalObject.h>
JSGlobalContextRef jsGlobalContext = JSGlobalContextCreate(NULL);
JSC::JSGlobalObject *pGlobalObject = toJS(ctx)->vmEntryGlobalObject();
有了JSGlobalObject,可以直接通过vm()获取vm对象,这样我们就可以实例化一个Debugger了,接着我们再调用attach方法就可以将当前debugger设置到JS虚拟机中进行运行插桩了。但是我们还要调用另外两个方法:setBreakpoint设置具体断点以及activateBreakpoints将断点激活。
这样我们就做完了第一步(实例化Debugger的时候需要将virtualj接口都有实现,我们可以继承Debugger进行简单实现),我们来编译运行看下。
果不其然,事情并没有这么简单,直接编译报错:
error: undefined reference to 'typeinfo for JSC::Debugger'
由于对c++不是很熟,所以不明白为什么会报错找不到Debugger,明明已经include了。研究了下,最后请教c++老手知道了是由于开了rtti导致的,rtti表示运行时类型识别,进行dynamic_cast时要用到。所以这里我们只需要在编译选项里关闭rtti就行了:
cppFlags "-fno-rtti"
activateBreakpoints线程问题
调用activateBreakpoints之后会调用recompileAllJSFunctions
void JSDebuggerImpl::recompileAllJSFunctions() {
JSC::JSLockHolder lock(vm());
Debugger::recompileAllJSFunctions();
}
我们可以看到这里有个lock方法,这是JSC在进行一些关键操作时的加锁,所以必须运行在JS线程。但是我们收到相关指令是在socket线程,所以这里我们必须要将相关指令转移到JS线程运行,就如文章开头所绘的时序图。
JS线程Runnable队列
JS线程有个loop循环不断处理相关事件(比如JS加载、图片加载等),我们在这个loop里面加入runnable队列的处理逻辑:
std::list<std::function<void()>> runnableList;
void handleRunnable(){
if(runnableList.empty()){
return;
}
std::list<std::function<void()>> tempList;
pthread_mutex_lock(&runnableMutex);
for(std::function<void()> funcAndArg:runnableList){
tempList.push_back(funcAndArg);
}
runnableList.clear();
pthread_mutex_unlock(&runnableMutex);
for(std::function<void()> function:tempList){
function();
}
runnableList.clear();
}
void runInJsThread(std::function<void()> runnable) {
pthread_mutex_lock(&runnableMutex);
runnableList.push_back(runnable);
pthread_mutex_unlock(&runnableMutex);
}
我们将需要执行的逻辑放到闭包队列中,这样就不需要设计特定的Event了,可以当做通用的消息队列来处理各种逻辑。
didParseSource
调用JSEvaluateScript加载JS文件之后,ScriptDebugServer的didParseSource会被回调用于记录url和sourceId和映射关系:
void JSDebuggerImpl::didParseSource(JSC::SourceID sourceId, const Script& script){
NSLog("didParseSource sourceId=%d",sourceId);
String url = script.url;
if (!url.isEmpty())
{
m_mapSourceIdToUrl.add(sourceId, url);
m_mapSourceUrlToId.add(url, sourceId);
}
}
这里的sourceId在之后设置断点时会用到。
setBreakpoint
Breakpoint(SourceID sourceID, unsigned line, unsigned column...)
显而易见,断点由sourceID、行号、列号组成,这很好理解。
didPause
以上Debug初始化设置之后,本以为就可以将断点断下来了。但是实际上并没有,当时很困惑,不知道哪里出错了。虽然没有断下来,但是却发现Debugger的didPause方法会在执行到断点逻辑时被回调,这里貌似有些文章。最后在另外一个同学那里了解到这是正常的。原来执行到断点时JSC只是给我们一个回调,至于怎么做需要我们来处理。所以我们需要在这里将JS线程wait住即可达到断点暂停的效果。同时这个方法也会将当前的栈帧信息推过来,这样我们就可以获取Stack Frame 和变量推给工具端展示给开发者。
Stack Frame & Properties
void didPause(JSC::ExecState &es, JSC::JSValue callFrames, JSC::JSValue exception)
这个方法是整个debug的核心,我们可以看到这里将callFrames和exception推过来,我们需要对其进行处理来获取整个调用栈和变量。
Stack Frame
JSValueRef lineRef = GetNativeProperty(ctx, callFrame, JSStringCreateWithUTF8CString("line"));
JSValueRef functionNameRef = GetNativeProperty(ctx, callFrame, JSStringCreateWithUTF8CString("functionName"));
JSValueRef sourceIdRef = GetNativeProperty(ctx, callFrame, JSStringCreateWithUTF8CString("sourceID"));
这里是对callFrame的处理,可以获取方法名、所在源码文件以及行号用于工具端定位到相应源码。
当然这只是对栈顶方法的处理,我们要获取整个调用栈的话还要递归调用:
JSValueRef callerRef = GetNativeProperty(ctx, currentFrame, JSStringCreateWithUTF8CString("caller"));
这样我们就可以获取所有调用方法的栈帧了。
Get Properties
scopeChain & thisObject
Debug另一个重要作用就是获取当前方法的局部变量和全局变量信息,我们来看下这里的处理逻辑:
JSValueRef scopeChainRef = GetNativeProperty(ctx, callFrame, JSStringCreateWithUTF8CString("scopeChain"));
scopeChain,即作用域链,用于在标识符解析中变量查找。简单说就是通过它可以获取当前方法的所有变量,包括局部变量、upvalues及global变量。
bool JSDebuggerImpl::ProcessScopeChain(JSContextRef ctx, JSObjectRef scopeChain, tiny::xarray &locals, tiny::xarray &upValues, tiny::xarray &globals, JSValueRef thisValueRef, tiny::TinyJson &mapId2Var, std::map<JSValueRef, IdType> &crossTable)
{ JSPropertyNameArrayRef nameArrayRef = JSObjectCopyPropertyNames(ctx, scopeChain);
size_t nameArrayCount = JSPropertyNameArrayGetCount(nameArrayRef);
for (size_t i = 0; i < nameArrayCount; ++i)
{
JSValueRef scopeRef = JSObjectGetPropertyAtIndex(ctx, scopeChain, static_cast<unsigned>(i), &exception);
if (scopeRef && !exception && JSValueIsObject(ctx, scopeRef))
{
JSObjectRef scope = JSValueToObject(ctx, scopeRef, &exception);
if (i == 0)
{//index 0 for "locals"
ProcessScope(ctx, scope, locals, mapId2Var, crossTable);
}
else if (i >= 1 && i <= nameArrayCount - 2)
{//index 1 to (n-2) for "upvalues"
ProcessScope(ctx, scope, upValues, mapId2Var, crossTable);
}
else if (i == nameArrayCount - 1)
{//index (n-1) for "globals"
if (globals.Count() == 0)
{
ProcessScope(ctx, scope, globals, mapId2Var, crossTable);
}
}
}
}
我们可以看到scopeChain是个数组,第一个存放的是local变量;中间存放的是upValues;最后一个元素存放的是global变量。
JSValueRef thisObjectRef = GetNativeProperty(ctx, callFrame, JSStringCreateWithUTF8CString("thisObject"));
这里面的thisObject也是global对象,我们也要解析它放到global中。
变量解析
bool JSObjectProcessProperty(JSContextRef ctx, JSObjectRef objRef, std::function<void(JSStringRef, JSValueRef)> fun)
{
JSPropertyNameArrayRef nameArrayRef = JSObjectCopyPropertyNames(ctx, objRef);
auto nameArraySize = JSPropertyNameArrayGetCount(nameArrayRef);
for (decltype(nameArraySize) i = 0; i < nameArraySize; ++i){
JSStringRef nameRef = JSPropertyNameArrayGetNameAtIndex(nameArrayRef, i);
JSValueRef valRef = JSObjectGetProperty(ctx, objRef, nameRef, &exception);
}
}
这里遍历JSObjectRef所有成员变量,对每一个成员变量再根据其类型进行处理
switch (JSValueGetType(ctx, valRef)){
case kJSTypeUndefined:
value.put(Variable_Key_Type,"undefined");
break;
case kJSTypeNull:
value.put(Variable_Key_Type,"null");
break;
case kJSTypeBoolean:
value.put(Variable_Key_Type,"boolean");
value.put(Variable_Key_Value,JSValueToBoolean(ctx, valRef));
break;
case kJSTypeNumber:
value.put(Variable_Key_Type,"number");
number = JSValueToNumber(ctx, valRef, nullptr);
break;
case kJSTypeString:
value.put(Variable_Key_Type,"string");
value.put(Variable_Key_Value,JSStringRefToStdString(JSValueToStringCopy(ctx, valRef, nullptr), ctx));
break;
case kJSTypeObject:
{
JSObjectRef objRef = JSValueToObject(ctx, valRef, &exception);
if (objRef && !exception)
{
if (JSObjectIsFunction(ctx, objRef))
{//function
value.put(Variable_Key_Type,"function");
} else{//normal
value.put(Variable_Key_Type,"object");
m_varJSValue.emplace(IntegerToString(varId),objRef);
}
}
将Object放到std::map<std::string, JSObjectRef> m_varJSValue;缓存起来,以待GetProperties时使用.
这样我们就获取了当前栈帧的所有变量。我们即可将栈帧和变量信息推给工具端。
{
"method":"Debugger.Paused",
"parms":{
"globals":[1,2,3],
"frames":[
{
"url":"BoxRotate.js",
"lineNumber":34,
"funcName":"fun1",
"upvalues":[1,2,3],
"locals":[1,2,3]
}
]
}
}
工具端收到栈帧信息即展示,同时定位到相应源码文件。这块逻辑课参考Debug插件端原理
GetProperties
工具端在展示栈帧的同时,也有一块变量区,我们点开即会发送指令给程序端,让其解析相关变量:
{
"id": 1,
"domain": "Debugger",
"commands": {
"name": "GetProperties",
"parameters": [1,18,19,36,37,38]
}
}
使用变量id从m_pMapToValue获取对应的值,如果是object类型,从m_varJSValue获取对应的object对象,接着解析之。
bool JSDebuggerImpl::analyticJSObject(std::string vardId,tiny::xarray &varIdTable)
{
auto iter = m_varJSValue.find(vardId);
if (iter != m_varJSValue.end() && m_debugCtx)
{
return JSObjectProcessProperty(m_debugCtx, iter->second, [&](JSStringRef nameRef, JSValueRef valRef) {
IdType subId = BuildValueAndGetId(m_debugCtx, valRef, nameRef, *m_pMapId2Var, crossTable);;
if (subId > 0)
{
varIdTable.add(subId);
}
});
}
return false;
}
但是这里有个线程问题,就是JSObjectCopyPropertyNames必须在JS线程执行,而我们收到的指令是在socket线程,所以必须转到JS线程。我们之前设计了JS线程通用事件处理对列,是不是可以直接用呢?
很明显不行。因为此时的JS线程处于wait状态,我们把事件放到对列里面也不会立即执行。所以这里我们需要临时notify JS线程,再获取变量值。
断点后事件队列
int BreakpointHitCallback(...) {
m_breakpointCV.wait(lck);
handleLogicAfterBreakPoint();
}
收到GetProperties指令时,我们将相关逻辑放到runnableList里,同时临时m_breakpointCV.notify_one唤醒JS线程,进入相关事件队列处理逻辑handleLogicAfterBreakPoint
std::list<std::function<void()>> runnableList;
/**
* 断点pause之后吧,我们可能需要js处理一些事情,譬如请求部分变量,
这时我们必须在js线程请求,就需要临时notify js线程
*/
void runLogicAfterBreakPoint(std::function<void()> runnable) {
pthread_mutex_lock(&runnableMutex);
runnableList.push_back(runnable);
pthread_mutex_unlock(&runnableMutex);
}
void handleLogicAfterBreakPoint(){
while(!runnableList.empty()){
std::list<std::function<void()>> tempList;
pthread_mutex_lock(&runnableMutex);
for(std::function<void()> funcAndArg:runnableList){
tempList.push_back(funcAndArg);
}
runnableList.clear();
pthread_mutex_unlock(&runnableMutex);
for(std::function<void()> function:tempList){
function();
}
tempList.clear();
//处理完临时需求之后,我们需要再次把js线程挂起,
// 这里要再次判断下队列是否有新进入的待处理任务,fix bug
if(runnableList.empty()){
std::unique_lock<std::mutex> lck(m_breakpointMtx);
m_breakpointCV.wait(lck);
}
}
}
这里需要循环卡在这里,每次执行完之后再将JS线程wait起来知道下次再被唤醒,如果此时runnableList为空,就说明没有事件要处理了,只是将JS线程恢复运行。
} else if (command == "GetProperties") {
runLogicAfterBreakPoint([this,id, paramsString]() {
m_pDebugger->analyticJSObject(val, varIdTable);
});
//将断点唤醒,去取变量
m_breakpointCV.notify_one();
GetPoperties Crash
对一些稍微复杂点的JS程序,我们在解析变量时遇到了一个很诡异的crash:
譬如在解析QGBindingCanvas时,却拿到了QGBindingCanvasContext的变量,这时取变量时肯定就因为访问非法地址而crash。
这里先解释一下JSC的赋值和取值原理:
JSC只是js语法的解析引擎,真正执行的是底层实现,譬如canvas2D接口需要我们在底层调用opengl实现。
所以我们需要在c++层构建一个c++对象与JSObjectRef绑定,这样真正执行的就是这个绑定的c++对象了。
JSObjectRef qg_callAsConstructor(JSContextRef ctx, JSObjectRef constructor, size_t argc, const JSValueRef argv[], JSValueRef* exception) {
QGBindingBase* pClass = (QGBindingBase*)(JSObjectGetPrivate( constructor ));
JSClassRef jsClass = QGApp::instance()->getJSClassForClass(pClass,false);
JSObjectRef obj = JSObjectMake( ctx, jsClass, NULL );
QGBindingBase* instance = (QGBindingBase*)NSClassFromString(pClass->toString().c_str());
instance->initWithContext(ctx, obj, argc, argv);
JSObjectSetPrivate( obj, (void *)instance );
return obj;
}
再来看下构建JSObjectRef的过程:
NSObjectFactory::fuc_map_type* base = NSObjectFactory::getFunctionMap();
for(NSObjectFactory::fuc_map_type::iterator it = base->begin(); it != base->end(); it++)
{
if( name.find("_get_") != string::npos ) {
int is_member_func = name.find(base_obj_tmp);
if (is_member_func != string::npos)
{
// We only look for getters - a property that has a setter, but no getter will be ignored
properties->setObject(NSStringMake(name.substr(pos + strlen("_get_"))), name);
}
}
JSClassDefinition classDef = kJSClassDefinitionEmpty;
classDef.staticValues = values;
classDef.staticFunctions = functions;
JSClassRef js_class = JSClassCreate(&classDef);
这里我们可以看到,主要是在fuc_map里面匹配查找pClass的方法的属性,再赋给JSClassRef,这样在JS层使用时就把它的方法和属性限定好了。我们在调用相应方法或者获取属性值时也是在binding层调用对应方法获取实际的值。
但是这有一个隐藏bug:
int is_member_func = name.find(base_obj_tmp);
这里通过类名局部匹配:
譬如方法"_ptr_to_QGBindingCanvas_get_height"会匹配给QGBindingCanvas,这没问题;但是按现在规则"_ptr_to_QGBindingCanvasContext_get_scale"也会匹配给QGBindingCanvas。
所以我们解析变量的时候会解析到异常地址而crash.
这里只需如此修改:
int is_member_func = name.find("_"+base_obj_tmp+"_");
步调step
stepInto
进入子方法
stepOver
当前方法按步调试
stepOut
跳出当前方法
小结
本文对debug主要的流程和概念做了简单的总结:
image.png
以上。
网友评论