2019-06-18

2019-06-18  本文已影响0人  吴亨

[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

以上。

上一篇下一篇

猜你喜欢

热点阅读