技术预计阅读 15 分钟5666 字0 次浏览

Antlr4系列⑨:调试器进阶之Step Over与条件断点

Step Into/Over/Out、调用栈展示、条件断点

目录

上一篇文章实现了调试器最基础的能力:断点和单步(严格来说是Step Into——不管下一条语句在哪一层调用深度,都会在那里暂停)。这篇文章我们把单步完善成三种更贴近真实调试器的方式——Step Into / Step Over / Step Out,并且加上条件断点调用栈展示。同样,本篇不涉及.g4语法改动,都是宿主程序层面的增强。

一、思路设计

  1. Step Into / Over / Out的区别

    • Step Into(进入):不管下一条语句处于多深的调用层级,走到哪就停在哪。如果下一条语句正好是一次函数调用,效果就是"扎进函数体内部"。
    • Step Over(跳过):把接下来遇到的函数调用当成一个整体执行完,不进入其内部,只在调用深度回到不超过当前深度时才暂停。
    • Step Out(跳出):不管函数体里还剩多少语句,一路跑到当前函数返回、调用深度小于当前深度(也就是回到了调用者所在的层级)才暂停。

    可以看到,三者的区别本质上是"暂停条件里对调用深度的要求不同",这也是上一篇文章特意先实现调用栈(callStack)的原因——没有调用深度这个概念,Step Over/Out根本无从谈起。

  2. 条件断点

    有时候我们只关心"变量i等于3的时候到底发生了什么",而不想在循环的每一次都手动放行。条件断点就是在命中断点所在的行时,先按语言本身的语法把一个条件表达式求值一遍,只有为真时才真正暂停。

二、语法实现

1. DebugController:从"是否单步"升级成"单步模式+目标深度"

/**
 * 单步模式:NONE表示不处于单步(只按断点暂停);
 * INTO表示下一条语句无论深度多少都暂停;
 * OVER表示只在深度回到不超过targetDepth时才暂停;
 * OUT表示只在深度小于targetDepth时才暂停
 */
private enum StepMode {NONE, INTO, OVER, OUT}

private volatile StepMode stepMode = StepMode.NONE;
private volatile int targetDepth = 0;

/**
 * 由解释器线程调用:每执行一条语句之前检查是否需要暂停。
 * breakpointHit由调用方(MyRuleSetVisitor)提前算好传进来,因为条件断点的条件表达式
 * 需要用visitor自己去visit求值,DebugController本身并不持有visitor,没办法自己算。
 */
public void checkpoint(int line, boolean breakpointHit, int depth, List<String> callStack, Scope scope) {
    boolean shouldPause = breakpointHit || matchesStepMode(depth);
    if (!shouldPause) {
        return;
    }
    stepMode = StepMode.NONE;
    try {
        pauseSignal.put(new PauseInfo(line, depth, callStack, scope));
        resumeSignal.take();
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}

private boolean matchesStepMode(int depth) {
    switch (stepMode) {
        case INTO:
            return true;
        case OVER:
            return depth <= targetDepth;
        case OUT:
            return depth < targetDepth;
        default:
            return false;
    }
}

public void stepInto() {
    stepMode = StepMode.INTO;
    resumeSignal.offer(true);
}

/**
 * @param currentDepth 发出指令时所在的调用深度,通常就是最近一次PauseInfo里的depth
 */
public void stepOver(int currentDepth) {
    stepMode = StepMode.OVER;
    targetDepth = currentDepth;
    resumeSignal.offer(true);
}

public void stepOut(int currentDepth) {
    stepMode = StepMode.OUT;
    targetDepth = currentDepth;
    resumeSignal.offer(true);
}

targetDepth记录的是发出指令那一刻所在的调用深度。以stepOver为例:命令发出时深度是D,只要接下来遇到的语句深度大于D(说明正处在被跳过的函数调用内部),就不理会;一旦某条语句的深度又回到了≤ D,说明已经跳出了那次调用(或者调用压根就没有发生更深的调用),此时暂停。stepOut同理,只是把"回到不超过D"换成了"严格小于D"——因为如果当前就在某次调用内部,"跳出"至少要回到比当前更浅一层才算数。

2. 条件断点

private final Map<Integer, RuleSetParser.CondContext> conditionalBreakpoints = new ConcurrentHashMap<>();

/**
 * 增加一个条件断点:只有condition在命中该行时求值为true才真正暂停。
 * condition是提前用RuleSetParser.cond()规则单独解析出来的语法树节点
 */
public void addConditionalBreakpoint(int line, RuleSetParser.CondContext condition) {
    conditionalBreakpoints.put(line, condition);
}

这里有个值得一提的细节:condition是提前单独解析好的一段cond语法树,而不是每次命中都重新解析文本。antlr4生成的RuleSetParser不仅有解析整个程序入口的prog()方法,语法文件里的每一条规则都会生成一个同名的公开方法(比如cond()calcu()),可以直接拿来复用,单独解析一小段符合该规则的文本,不需要每次都套上一个完整的程序。设置条件断点时就是这么做的:

RuleSetLexer condLexer = new RuleSetLexer(CharStreams.fromString("i == 3"));
RuleSetParser condParser = new RuleSetParser(new CommonTokenStream(condLexer));
RuleSetParser.CondContext condition = condParser.cond();
debugController.addConditionalBreakpoint(3, condition);

真正求值放在MyRuleSetVisitor.visitMain里,因为只有持有visit()方法的visitor自己才能对这段语法树求值:

@Override
public VisitorResult visitMain(RuleSetParser.MainContext ctx) {
    if (debugController != null) {
        int line = ctx.start.getLine();
        boolean breakpointHit = debugController.hasUnconditionalBreakpoint(line);
        if (!breakpointHit) {
            RuleSetParser.CondContext condition = debugController.getCondition(line);
            breakpointHit = condition != null && visit(condition).getBool();
        }
        debugController.checkpoint(line, breakpointHit, callStack.size(), new ArrayList<>(callStack), currentScope);
    }
    return visitChildren(ctx);
}

关于求值的时机:条件表达式是在这一行语句即将执行、但还没有执行的时候求值的,这正是我们希望的语义——断点条件看到的应该是"执行这一行之前"的变量状态,和断点本身暂停的时机保持一致。

关于调用深度和调用栈:callStack.size()就是当前的调用深度,直接复用了上一篇文章为了防止死递归而维护的那个Deque<String>;把它拷贝一份(new ArrayList<>(callStack))传给DebugController,是因为callStack本身在解释器线程里还会持续变化,拷贝一份快照才能保证控制台线程展示的调用栈是暂停那一刻的真实状态,不会因为后续的修改而错乱。

三、测试代码与执行结果

1. Step Into / Out / Over

private static void stepDemo() throws InterruptedException {
    String expression =
            "function square(x) { \n" +           // 第1行
                    "    number result = x * x \n" +  // 第2行
                    "    return result \n" +           // 第3行
                    "} \n" +                            // 第4行
                    "number a = square(2) \n" +          // 第5行,断点
                    "print(a) \n" +                       // 第6行
                    "number b = square(3) \n" +           // 第7行,断点
                    "print(b)";                             // 第8行
    ...
    DebugController debugController = new DebugController();
    debugController.addBreakpoint(5);
    debugController.addBreakpoint(7);
    runWithScriptedCommands(prog, debugController, new String[]{"into", "out", "c", "over", "c"});
}

square(2)这次调用用into+out:先扎进函数体内部看一眼,再直接跑到函数返回;square(3)这次调用改用over:把整个调用当成一步跳过去。执行结果如下:

[调试] 暂停在第5行,调用深度:0,调用栈:[],可见变量:{}
[调试] 下发指令:into
[调试] 暂停在第2行,调用深度:1,调用栈:[square],可见变量:{x=2.0}
[调试] 下发指令:out
[调试] 暂停在第6行,调用深度:0,调用栈:[],可见变量:{a=4.0}
[调试] 下发指令:c
4.0
[调试] 暂停在第7行,调用深度:0,调用栈:[],可见变量:{a=4.0}
[调试] 下发指令:over
[调试] 暂停在第8行,调用深度:0,调用栈:[],可见变量:{a=4.0, b=9.0}
[调试] 下发指令:c
9.0
[调试] 程序执行结束

第一次在第5行暂停后下发into,果然停在了square函数体内部的第2行,调用深度变成1,调用栈里出现了square,能看到参数x=2.0;接着下发out,直接跳过函数体剩下的部分(第3行的return根本没有单独停下来),一路停在了调用返回之后的第6行,此时a已经被正确赋值为4.0;第二次在第7行暂停后改用oversquare(3)函数体内部完全没有出现暂停,直接停在了调用结束之后的第8行,b已经是9.0。三种单步方式的区别清清楚楚。

2. 条件断点

private static void conditionalBreakpointDemo() throws InterruptedException {
    String expression =
            "number i = 0 \n" +
                    "while (i < 6) { \n" +
                    "    print(i) \n" +     // 第3行,条件断点:i == 3
                    "    i = i + 1 \n" +
                    "}";
    ...
    RuleSetLexer condLexer = new RuleSetLexer(CharStreams.fromString("i == 3"));
    RuleSetParser condParser = new RuleSetParser(new CommonTokenStream(condLexer));
    RuleSetParser.CondContext condition = condParser.cond();

    DebugController debugController = new DebugController();
    debugController.addConditionalBreakpoint(3, condition);
    runWithScriptedCommands(prog, debugController, new String[]{"c"});
}

执行结果如下:

0.0
1.0
2.0
[调试] 暂停在第3行,调用深度:0,调用栈:[],可见变量:{i=3.0}
[调试] 下发指令:c
3.0
4.0
5.0
[调试] 程序执行结束

循环打印i为0、1、2的时候条件断点i == 3都不成立,直接跑过去;直到i变成3才真正暂停下来,下发c之后继续打印3、4、5直至循环结束,和条件断点的语义完全一致。

四、遗留的问题

到这里,一个具备断点(含条件断点)、Step Into/Over/Out、调用栈、变量查看的调试器雏形已经搭起来了,核心机制其实非常轻量——一个作用于两个线程之间的"握手"队列,加上在语句粒度上打卡。它虽然简陋,但对理解主流IDE(比如jdb、各种脚本语言的调试适配器)背后的基本原理已经足够了:无论多么复杂的调试器,底层往往都是"可暂停的解释执行 + 深度/条件判断"的某种变体。

我们的小型DSL系列也告一段落,接下来会转向语言本身的补完,比如数组/列表类型的支持,敬请期待。

花开空白

西安

相关文章

评论(0)

还没有评论,来抢沙发吧

发表评论