Antlr4系列⑧:调试器基础之断点与单步执行
双线程+阻塞队列实现暂停/恢复、断点、单步、变量查看
目录
上一篇文章实现了自定义函数和调用栈,我们的小型DSL已经是一门功能齐全的编程语言了。从本篇开始,我们尝试实现一个更有意思的能力——调试器:设置断点、单步执行、暂停时查看变量。这篇文章先实现最基础的部分:断点和单步。注意本篇不涉及.g4语法的任何改动,纯粹是宿主程序(Java)层面新增的能力。
一、思路设计
我们的解释器是基于Visitor模式的树遍历,本质上是一次"一口气从头跑到尾"的递归函数调用,天然没有"暂停点"。要实现断点和单步,核心问题是:怎么让解释器在执行到某一条语句时暂停下来,等外部指令决定它是继续跑还是再往前挪一步?
这里采用的方案是把解释器的执行放到一个独立线程里跑,在每条语句执行之前"打卡"一次:
- 如果打卡的这一行命中了断点,或者当前处于单步模式,就把这个线程阻塞住。
- 阻塞期间,另一个"控制台线程"(在真实场景里就是接收用户输入的线程)可以拿到暂停时的行号和当前作用域,展示给用户看。
- 用户输入
n(单步)或c(继续)之后,控制台线程通知被阻塞的解释器线程继续往下走。
两个线程之间的通信,本质上是两次"握手":解释器线程要让控制台线程知道"我暂停了",控制台线程要让解释器线程知道"可以继续了"。这里用两个容量为1的阻塞队列(ArrayBlockingQueue)分别表示这两个方向的信号,谁先到谁就等着,不会出现请求丢失的问题,比用wait/notify手写锁更不容易出错。
二、语法实现
1. DebugController:两个线程之间的桥梁
public class DebugController {
private final Set<Integer> breakpoints = ConcurrentHashMap.newKeySet();
/**
* 是否处于单步模式,只对触发暂停后的下一条语句生效,消费一次就会被重置
*/
private volatile boolean stepMode = false;
private final BlockingQueue<PauseInfo> pauseSignal = new ArrayBlockingQueue<>(1);
private final BlockingQueue<Boolean> resumeSignal = new ArrayBlockingQueue<>(1);
public void addBreakpoint(int line) {
breakpoints.add(line);
}
/**
* 由解释器线程调用:每执行一条语句之前检查是否需要暂停
*/
public void checkpoint(int line, Scope scope) {
if (!stepMode && !breakpoints.contains(line)) {
return;
}
stepMode = false;
try {
pauseSignal.put(new PauseInfo(line, scope));
// 阻塞在这里,直到控制台线程调用resume或stepNext
resumeSignal.take();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
/**
* 由解释器线程调用:程序正常结束或者抛出异常终止时调用,发送一个哨兵值告知控制台线程"结束了"
*/
public void finish() {
try {
pauseSignal.put(PauseInfo.FINISHED);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
/**
* 由控制台线程调用:阻塞直到解释器线程暂停下来(命中断点/单步/程序结束),返回当时的信息
*/
public PauseInfo awaitPause() throws InterruptedException {
return pauseSignal.take();
}
/**
* 由控制台线程调用:让解释器线程继续跑,直到遇到下一个断点或程序结束
*/
public void resume() {
stepMode = false;
resumeSignal.offer(true);
}
/**
* 由控制台线程调用:只放行到下一条语句就再次暂停
*/
public void stepNext() {
stepMode = true;
resumeSignal.offer(true);
}
/**
* 解释器每次暂停时的快照:暂停在第几行、当时的作用域是什么
*/
public static class PauseInfo {
static final PauseInfo FINISHED = new PauseInfo(-1, null);
public final int line;
public final Scope scope;
PauseInfo(int line, Scope scope) {
this.line = line;
this.scope = scope;
}
public boolean isFinished() {
return line == -1;
}
}
}
关于
stepMode只生效一次:n(单步)应该只让"紧接着的下一条语句"暂停,而不是之后所有语句都暂停,所以checkpoint一旦因为stepMode触发了暂停,会立刻把它重置为false。如果用户接下来想继续单步,需要再次调用stepNext()。
2. 打卡点:visitMain
前面几篇文章里,main语法对应的正是"一条语句"(变量定义、赋值、打印、if、while、break、continue……),天然就是解释器执行的最小步进单位,所以打卡点就选在visitMain:
/**
* 调试控制器,为null时表示不启用调试,按普通方式一路执行到底,不产生任何额外开销
*/
private final DebugController debugController;
public MyRuleSetVisitor() {
this(null);
}
public MyRuleSetVisitor(DebugController debugController) {
this.debugController = debugController;
}
/**
* main对应语法里的每一条语句,是解释器执行的最小步进单位。在真正执行这条语句之前先"打卡"一下,
* 如果启用了调试且命中了断点或处于单步模式,这里会阻塞住,等待调试控制台发出继续/单步指令。
*/
@Override
public VisitorResult visitMain(RuleSetParser.MainContext ctx) {
if (debugController != null) {
debugController.checkpoint(ctx.start.getLine(), currentScope);
}
return visitChildren(ctx);
}
debugController允许传null,这样不需要调试的场景(前几篇文章的用法)不会受到任何影响,也不会有额外开销——这是给已有功能"平滑加装"新能力的常见做法。
3. 查看变量:区分"自己的变量"和"看得见的变量"
暂停时要把变量展示出来,但Scope当前只保存"当前这一层自己的变量"。如果只展示这一层,很多时候会是空的(比如while循环体block刚进入时,自己还没有定义任何变量,i是在外层定义的)。所以在Scope里补充一个方法,沿着作用域链把所有能看到的变量都合并起来,内层同名变量覆盖外层的:
/**
* 返回当前这一层作用域自己定义的变量(不含外层),主要给调试器展示"当前作用域内的变量"用
*/
public Map<String, Object> getOwnVariables() {
return Collections.unmodifiableMap(variables);
}
/**
* 返回当前位置沿作用域链能看到的所有变量(含外层),内层同名变量会覆盖外层的,
* 这才是调试时"当前所有可见变量"应该展示的完整视图
*/
public Map<String, Object> getVisibleVariables() {
List<Scope> chain = new ArrayList<>();
for (Scope scope = this; scope != null; scope = scope.parent) {
chain.add(scope);
}
Map<String, Object> merged = new LinkedHashMap<>();
for (int i = chain.size() - 1; i >= 0; i--) {
merged.putAll(chain.get(i).variables);
}
return merged;
}
三、测试代码与执行结果
因为这篇文章的重点是机制本身,没有接入真实的控制台输入,用一个commands数组模拟依次下发的调试指令,实际使用时把这里换成读取Scanner的用户输入即可:
private static void debugDemo() throws InterruptedException {
String expression =
"number i = 0 \n" + // 第1行
"while (i < 3) { \n" + // 第2行
" i = i + 1 \n" + // 第3行,断点设在这一行
" print(i) \n" + // 第4行
"} \n" + // 第5行
"print(\"done\")"; // 第6行
RuleSetLexer lexer = new RuleSetLexer(CharStreams.fromString(expression));
RuleSetParser parser = new RuleSetParser(new CommonTokenStream(lexer));
RuleSetParser.ProgContext prog = parser.prog();
DebugController debugController = new DebugController();
debugController.addBreakpoint(3);
MyRuleSetVisitor visitor = new MyRuleSetVisitor(debugController);
// 解释器放到独立线程里跑,避免阻塞住控制台线程
Thread interpreterThread = new Thread(() -> {
try {
visitor.visit(prog);
} finally {
debugController.finish();
}
});
interpreterThread.start();
// 模拟依次下发的调试指令:第一次命中断点直接continue,第二次改成单步,之后再continue到结束
String[] commands = {"c", "n", "c", "c"};
int commandIndex = 0;
while (true) {
DebugController.PauseInfo pause = debugController.awaitPause();
if (pause.isFinished()) {
System.out.println("[调试] 程序执行结束");
break;
}
System.out.println("[调试] 暂停在第" + pause.line + "行,当前可见变量:" + pause.scope.getVisibleVariables());
String command = commands[commandIndex++];
if ("n".equals(command)) {
System.out.println("[调试] 下发指令:n(单步)");
debugController.stepNext();
} else {
System.out.println("[调试] 下发指令:c(继续)");
debugController.resume();
}
}
interpreterThread.join();
}
执行结果如下:
执行:
number i = 0
while (i < 3) {
i = i + 1
print(i)
}
print("done")
[调试] 暂停在第3行,当前可见变量:{i=0.0}
[调试] 下发指令:c(继续)
1.0
[调试] 暂停在第3行,当前可见变量:{i=1.0}
[调试] 下发指令:n(单步)
[调试] 暂停在第4行,当前可见变量:{i=2.0}
[调试] 下发指令:c(继续)
2.0
[调试] 暂停在第3行,当前可见变量:{i=2.0}
[调试] 下发指令:c(继续)
3.0
"done"
[调试] 程序执行结束
跟着日志走一遍:程序第一次跑到第3行(i = i + 1)命中断点暂停,此时i还是循环开始时的0.0;下发c让它继续,i自增为1.0并打印;第二次命中断点时i是1.0,这次改成下发n(单步),于是只往前走一条语句,停在第4行print(i)执行之前,此时i已经自增成了2.0但还没打印出来;再下发c,打印出2.0,循环继续,第三次命中断点时i是2.0;最后一次c放行到程序结束,打印3.0和"done"。全程和断点、单步的语义完全对得上。
四、遗留的问题
目前的单步只有一种粒度——"下一条语句",不管这条语句是不是函数调用。如果n所在的位置紧接着是一次函数调用,现在的实现会直接停在函数体内部的第一条语句上,这其实是通常所说的Step Into(进入函数内部)。但调试时经常还需要Step Over(把整个函数调用当成一步跳过去,不进入内部)和Step Out(跳出当前函数,停在调用它的地方),这两者都需要借助上一篇文章实现的调用栈来判断"当前跳到哪一层了"。下一篇文章我们就来实现这两种更精细的单步方式,并且把调用栈信息也展示出来。