断点与调用栈
程序命中断点后,调试 UI 需要渲染调用栈、作用域和变量树。这些数据由五个 DAP 请求驱动:threads、stackTrace、scopes、variables 加上之前的 setBreakpoints。
调试会话的运行时模型
调试适配器内部需要维护一个"调试对象模型",所有 DAP 请求都在这个模型上操作:
调试会话
└── Threads(线程)
└── StackFrames(调用栈帧)
└── Scopes(作用域:局部/全局)
└── Variables(变量)
└── Variables(嵌套变量:数组元素/对象属性)// 内部模型数据结构
interface DebugThread {
id: number;
name: string;
frames: DebugStackFrame[];
}
interface DebugStackFrame {
id: number;
name: string;
line: number;
column: number;
source: { path: string };
scopes: DebugScope[];
}
interface DebugScope {
name: string;
variablesReference: number; // 变量引用 ID,0 表示无子变量
expensive: boolean;
}
interface DebugVariable {
name: string;
value: string;
type: string;
variablesReference: number;
}variablesReference 是理解调试协议的关键:它是一个数字句柄,编辑器通过它按需展开变量。值为 0 表示该变量没有子项。
ThreadsRequest 线程列表
编辑器收到 stopped 事件后,首先请求线程列表:
function handleThreads(msg: DAPMessage, socket: any) {
const threads = debugModel.threads.map((t) => ({
id: t.id,
name: t.name
}));
sendResponse(socket, msg, { threads });
}{
"command": "threads",
"response": {
"success": true,
"body": {
"threads": [
{ "id": 1, "name": "Main Thread" }
]
}
}
}StackTraceRequest 调用栈帧
编辑器接着请求当前线程的调用栈:
function handleStackTrace(msg: DAPMessage, socket: any) {
const args = msg.arguments;
const threadId = args.threadId;
const thread = debugModel.threads.find((t) => t.id === threadId);
if (!thread) {
sendResponse(socket, msg, {}, false);
return;
}
// startFrame / levels 用于分页
const startFrame = args.startFrame || 0;
const levels = args.levels || 20;
const frames = thread.frames
.slice(startFrame, startFrame + levels)
.map((f) => ({
id: f.id,
name: f.name,
line: f.line,
column: f.column,
source: {
name: f.source.path.split(/[\\/]/).pop(),
path: f.source.path
}
}));
sendResponse(socket, msg, {
stackFrames: frames,
totalFrames: thread.frames.length
});
}栈帧要素
| 字段 | 说明 |
|---|---|
id | 栈帧唯一 ID,后续 scopes 请求依赖它 |
name | 帧名,如函数名 add 或 <anonymous> |
line / column | 当前执行位置(行从 1 开始,列从 0 开始) |
source.path | 源文件绝对路径 |
source.name | 编辑器标签页显示的文件名 |
栈帧 id 必须稳定且在会话内唯一:编辑器拿到栈帧后会用它的 id 请求作用域,任何复用或抖动都会导致 UI 错乱。
ScopesRequest 作用域
编辑器对每个栈帧请求作用域:
function handleScopes(msg: DAPMessage, socket: any) {
const frameId = msg.arguments.frameId;
const frame = findFrameById(frameId);
if (!frame) {
sendResponse(socket, msg, {}, false);
return;
}
const scopes = frame.scopes.map((s) => ({
name: s.name,
variablesReference: s.variablesReference,
expensive: s.expensive || false
}));
sendResponse(socket, msg, { scopes });
}作用域是"局部变量"和"全局变量"等集合的容器,expensive 标记告诉编辑器展开会消耗资源(如全局作用域),编辑器会延迟到用户主动展开。
VariablesRequest 变量展开
编辑器用 variablesReference 请求展开变量:
function handleVariables(msg: DAPMessage, socket: any) {
const variablesReference = msg.arguments.variablesReference;
const container = variablesById.get(variablesReference);
if (!container) {
sendResponse(socket, msg, {}, false);
return;
}
const variables = container.map((v) => ({
name: v.name,
value: v.value,
type: v.type,
variablesReference: v.variablesReference
}));
sendResponse(socket, msg, { variables });
}变量引用管理
适配器需要维护一个"引用表",把数字 ID 映射到实际的变量容器:
let nextVarRef = 1;
const variablesById = new Map<number, DebugVariable[]>();
// 为一个变量容器分配引用 ID
function registerVariables(vars: DebugVariable[]): number {
const ref = nextVarRef++;
variablesById.set(ref, vars);
return ref;
}
// 展开数组元素
function createArrayVariables(elements: any[]): DebugVariable[] {
return elements.map((value, index) => ({
name: `[${index}]`,
value: String(value),
type: typeof value,
variablesReference: 0
}));
}
// 展开对象属性
function createObjectVariables(obj: object): DebugVariable[] {
return Object.entries(obj).map(([key, value]) => ({
name: key,
value: String(value),
type: typeof value,
// 值本身是对象/数组时,递归分配引用
variablesReference:
typeof value === 'object' && value !== null
? registerVariables(createObjectVariables(value))
: 0
}));
}变量树示例
Locals (variablesReference: 1)
├── count: 42
├── message: "hello"
└── config (variablesReference: 2)
├── debug: true
└── timeout: 5000SetBreakpointsRequest 断点管理
断点按文件组织,编辑器每次变更都会整体重发该文件的所有断点:
function handleSetBreakpoints(msg: DAPMessage, socket: any) {
const args = msg.arguments;
const sourcePath = args.source.path;
// 校验断点行是否可命中(例如行号在文件范围内)
const bpResults = (args.breakpoints || []).map((bp: any) => {
const verified = isBreakpointValid(sourcePath, bp.line);
return {
verified,
line: bp.line,
// 条件断点
condition: bp.condition,
hitCondition: bp.hitCondition
};
});
breakpointsByFile.set(sourcePath, bpResults);
sendResponse(socket, msg, { breakpoints: bpResults });
}
function isBreakpointValid(sourcePath: string, line: number): boolean {
// 与真实调试器核对:行号在有效代码行集合中
const validLines = getExecutableLines(sourcePath);
return validLines.has(line);
}断点响应中 verified: false 的项会在 UI 中显示为空心圆圈,表示该断点未生效。
命中断点后的完整时序
程序执行到断点行时,适配器按序发送数据:
// 1. 发送 stopped 事件
sendEvent(socket, 'stopped', {
reason: 'breakpoint',
threadId: 1,
hitBreakpointIds: [bpId]
});
// 2. 编辑器随后依次请求 threads → stackTrace → scopes → variables调试面板的交互链条:
用户点击断点行
↓
编辑器发送 setBreakpoints
↓
程序执行到该行
↓
适配器发送 stopped(breakpoint)
↓
编辑器请求 threads
↓
编辑器请求 stackTrace(选中线程)
↓
编辑器请求 scopes(选中栈帧)
↓
编辑器请求 variables(用户展开时)断点类型扩展
条件断点
// launch.json / 断点气泡中设置 condition
{
"line": 12,
"condition": "i === 10"
}适配器在程序运行到断点行时求值条件,不满足则继续执行:
function shouldStopAtBreakpoint(sourcePath: string, line: number, frame: any): boolean {
const bps = breakpointsByFile.get(sourcePath) || [];
const bp = bps.find((b) => b.line === line);
if (!bp) return false;
// 条件断点:求值表达式
if (bp.condition) {
const result = evaluateInContext(bp.condition, frame);
return Boolean(result);
}
// 命中次数断点:如 >= 3 表示第 3 次命中才停
if (bp.hitCondition) {
hitCounts.set(`${sourcePath}:${line}`, (hitCounts.get(`${sourcePath}:${line}`) || 0) + 1);
return hitCounts.get(`${sourcePath}:${line}`) >= parseInt(bp.hitCondition, 10);
}
return true;
}日志断点(Logpoint)
VS Code 支持日志断点:不暂停程序,只在控制台打印表达式值。setBreakpoints 请求中的 logMessage 字段:
{
"line": 20,
"logMessage": "i = {i}"
}适配器命中日志断点时只发 output 事件,不发 stopped:
if (bp.logMessage) {
const rendered = renderLogMessage(bp.logMessage, frame);
sendEvent(socket, 'output', { category: 'console', output: rendered + '\n' });
continueExecution();
return;
}性能与缓存
变量树可能非常庞大,编辑器的展开是惰性的,但适配器应避免重复计算:
const scopeCache = new Map<number, DebugScope[]>();
function getScopesForFrame(frameId: number): DebugScope[] {
if (scopeCache.has(frameId)) {
return scopeCache.get(frameId)!;
}
const scopes = computeScopes(frameId);
scopeCache.set(frameId, scopes);
return scopes;
}
// 程序状态变化时清空缓存
function invalidateCaches() {
scopeCache.clear();
}断点、调用栈、变量三件套构成了调试面板的主体,掌握数据流后,下一步实现单步执行与表达式求值,让调试器真正"可控"。