基石调试器(M37 · 主线 B1;M39 补字节码执行器;M40 补 C VM)

目标:兑现北极星的后半句「出问题能快速定位」。M17 把报错与调用栈做扎实了, 但「程序跑错了但没报错」这类问题一直只能靠 打印() 大法。现在可以设断点、 单步、看变量与调用栈了——三个执行器都有,而且同一个程序、同一组断点、 同一串命令,三者的会话记录逐字相同。

bashjishi 调试 脚本.jsh                     # 停在第一句,之后听你指挥(默认树遍历)
jishi 调试 脚本.jsh --断点 12           # 直接跑到第 12 行
jishi 调试 脚本.jsh --断点 12,20 --不停在入口
jishi 调试 脚本.jsh --执行器 vm         # 换字节码执行器(Python VM)调
jishi 调试 脚本.jsh --执行器 cvm        # 换 C 虚拟机调(需先构建 jsvm 动态库)
jishi 调试 脚本.jsh -b 12 --命令 "变量;查看 单价 * 数量;继续"   # 自动跑一遍

暂停时长这样:

暂停(命中第 2 行的断点) 第 2 行 · 函数「倍」
→  2 │     令 果 = 值 * 2
(输入「帮助」看命令)
调试 > 变量
当前帧(函数「倍」)的变量:
  值 = 10
  (另有内建/临时变量没显示,要看用「变量 全部」)
调试 > 继续

一、命令一览

命令别名作用
继续c跑到下一个断点(没有断点就跑完)
单步s执行下一条语句,会进入被调用的函数
下一步n执行到本帧的下一条语句,不进入函数
跳出o跑完当前函数,回到调用它的地方
断点b列出断点;断点 12、断点 12,15 加断点
取消断点 12d删掉某个断点;清空断点 全删
变量v看当前帧的变量(变量 全部 连内建与脱糖临时变量)
查看 表达式p在当前帧里求值,例如 查看 单价 * 数量、查看 长度(列表)
栈bt调用栈(由内到外,每层标着它当前停在哪一行)
源码 [行号]l看当前行(或指定行)附近的源码
帮助h / ?命令表
退出q结束调试会话(程序不再往下跑;最终 块会照常执行)
(直接回车)重复上一条命令

停顿处的三种入口:--断点 行号、入口自动停(--不停在入口 可关)、 以及命令里现加(断点 12)。

二、出错时会看到什么

调试会话里程序出错时,会先打一份调试现场,再打正式的中文报错:

程序在第 3 行出错了 —— 出错时的现场(最后执行到 第 3 行 · 函数「倍」):
当前帧(函数「倍」)的变量:
  值 = 10
  果 = 20
调用栈(由内到外):
  #0 第    3 行  函数「倍」
  #1 第    6 行  函数「主」
  #2 第    9 行  模块顶层
(想复盘就改脚本、带上同一组断点再跑一次)

错误 E0301:找不到这个名字(找不到名字「无此名字」)
  ┌─ 演示.jsh:3:12
  │
  3 │     返回 果 + 无此名字
    │            ^
  │
调用链(从外到内):
  倍 ← 第 6 行

两段是互补的:正式报错说「错在哪」,调试现场说「错的时候变量是什么」 ——后者才是定位问题的关键信息。

三、现状与边界(如实说明)

能力现状
树遍历执行器(jishi 脚本.jsh 默认那条路)✅ 断点 / 单步(进/过/出)/ 变量 / 调用栈 / 表达式求值
Python VM(--执行器 vm)✅ 同上(M39 补齐)
C VM(--执行器 cvm)✅ 断点 / 单步 / 变量 / 调用栈(M40 补齐);就地求值暂不支持(C 侧没有「在任意帧上求表达式」的入口,会明确说明而不是给错值)
Node / Rust 宿主❌ 不适用(它们跑的是 Python 侧编译好的字节码,调试在 Python 侧进行)
条件断点(断点 12 如果 x > 3)❌ 未做(现在可以用「断点 + 查看」,或用 --命令 脚本)
观察点(值被改写时停下)❌ 未做

三个执行器的会话记录逐字相同:同一份脚本、同一组断点、同一串命令, --执行器 树遍历 / vm / cvm 的输出一字不差(含变量表、调用栈、 出错现场、退出 行为)。这不是巧合,是 tests/test_m39_vm_debugger.py (树遍历 vs VM)与 tests/test_m40_cvm_debugger.py(三者)里逐项对拍的结论 ——包括三种循环写法、闭包、类体、空块、回调、尝试/捕获/最终。

字节码执行器怎么会有「语句边界」(B5 的做法)

栈机只知道指令,但编译器知道:compile_stmt 会给每个语句的第一条指令 打一个标(指令上的 stmt_line),调试器只在标记处暂停。标「首指令」而不是 「语句范围内的每一行」是刻意的:

语句中部,不落在首指令上,于是循环头只停一次、循环体每轮都停—— 与树遍历里 A.For/A.While 只被 exec_stmt 进入一次完全一致;

弹出)专门用来挂标记,否则那些行在字节码里停不住、在树遍历里停得住。

不变式:打标行的集合 == AST 里语句行的集合。tests/test_m39_vm_debugger.py 拿 examples/ 与 tests/cases/ 全量对拍这条不变式——破了就意味着「某一行在 一个执行器停得住、在另一个停不住」。做 B5 时这条不变式抓出过两个真问题 (类体里的方法定义行、空块 : 行,原先两边都停不住)。

代价:挂上调试器(哪怕从不暂停)比不挂慢约 13%;不挂调试器时每条指令 只多一次 is not None 判断(几十纳秒)。

C VM 也补上了(M40)——做法与 Python VM 同源

C 侧的栈帧、局部槽都在 C 里,Python 侧看不见,所以 M39 当时把它判为「暂不做」。 M40 补上了,思路是不让调试器去猜执行器内部,而是给 C 侧加四个最小的只读入口 (cvm/include/jsvm.h):

入口作用
jsvm_set_stmt_marks收下语句起始表(与指令流等长,非 0 = 该指令是某语句的第一条)
jsvm_set_stmt_hook装语句钩子:每条语句首指令执行前回调一次
jsvm_frame_meta问一帧的「代码下标 + 调用点行列」(调用栈要用)
jsvm_dump_locals / jsvm_dump_globals把一帧的局部槽与单元、或全局变量交给宿主转换

三处刻意的设计:

  1. 语句起始表是单独一张 int32 数组,不往指令里加字段。指令宽度(6 个 int32)

是跨语言字节码格式的一部分(Node / Rust 宿主也按它读),为一个调试功能改公开 契约不划算。

  1. 钩子的「是不是语句起始」判断放在 C 主循环里,而不是每条指令都回调 Python:

不挂调试器时只多一次指针判空;挂了也只多一次数组查表。

  1. 值的所有权用「借用」:debug_local 回调收到的 JVal 是借的,宿主当场

转成 Python 对象,不引入第二套所有权规则。

「退出」是这里最麻烦的一处,值得单独说:它必须像另外两个执行器那样穿过 最终 块、又不能被 捕获 接住。做法是把 DebugQuit 当成一种卸载类信号 (宿主 is_signal 返回 3)交给 C 侧的异常分发:

踩到的两个坑(都已写进 AGENTS.md §2):

目标冲掉(此时 ip 还指着 END_FINALLY 的下一条)——表现是 最终 里的打印 执行了、程序却「正常结束」;

若又返回「请求中止」,最终 一行都不会执行(实测踩到)。所以会话记一个 _aborted_by_quit,进了退出流程就不再请求。

代价:挂调试器(哪怕从不暂停)比不挂慢约 8%(C 侧只多一次判空 + 查表)。 没做的:查看 表达式(就地求值)——C 侧没有「在任意帧上求一个表达式」的 入口,要做得把编译好的求值代码塞进当前帧执行,属另一件事;会话会明确说 「C 虚拟机暂不支持就地求值」,而不是给个错值。

其它已知边界

调试命令抢同一个 stdin(gdb 也是这个行为)。要稳定复现,用 --命令 脚本化, 或让程序改成从文件 / 命令行参数读数据。

不是独立语句(否则 那一行在 AST 里连节点都没有),断点加在上面不会触发。 CLI 加断点时会明确提示「这一行没有可停的语句」,而不是静默无效。

只有本函数自己绑定的名字,字节码这边保持一致(变量 里看不到 基数,但 查看 基数 读得到)。要看就用「查看」。

四、实现说明(给改这个模块的人)

模块结构是「一套会话 + 三个执行器」:debugger._Session 放执行器无关的部分 (断点表、命令循环、单步判定、源码显示、变量渲染、出错现场),子类只提供 「当前帧有多深、叫什么、变量怎么取、表达式怎么求」:

Debugger(树遍历)VmDebugger(Python VM)CvmDebugger(C VM)
钩子interpreter.exec_stmt 开头的 before_stmt + _eval_call 里的 push_frame/pop_framevm._execute 主循环里的 dbg.before_statement(line, frame)C 主循环里调宿主回调(jsvm_set_stmt_hook)
触发条件每个语句只有语句首指令(Instr.stmt_line != 0)同左(查 C 侧的语句起始表)
帧自己存一份 DebugFrame(多带 env)直接用 vm.frames(不另存,弹帧不会失步)向 C 侧问:(深度, 代码下标) 当句柄
变量读 Environment.vars读帧的局部槽 + 单元jsvm_dump_locals / jsvm_dump_globals 回调
求值interp.eval_expr把表达式编译成小代码对象,与所在帧共用局部槽与单元(vm.eval_in_frame)❌ 不支持(明确说明)

逐字节相同(tests/test_m37_debugger.py、tests/test_m39_vm_debugger.py、 tests/test_m40_cvm_debugger.py 都钉了)。

下一步 停在 深度 ≤ 暂停时深度、跳出 停在 深度 < 暂停时深度。 三个执行器的「深度」都指用户函数层数(模块顶层 = 0)。

错误是在语句执行到一半时冒出来的,等它传到 run() 时帧已经被弹干净了, 那时再问「当前帧」只会得到模块顶层(实现时真实踩到过);C VM 更严格—— 帧是按「第几层」编号的,弹帧后那个编号指向别的帧或越界,事后问只会得到空表。

一句 捕获: 就能把「退出」吞掉。三个执行器都要显式放行它: 树遍历在 interpreter._do_try 的控制信号元组里带上它;Python VM 在 vm._dispatch_exception 里让它跳过循环目标、只走处理器链,并且 vm.THROW 不许把它包装成用户异常;C VM 把它当卸载类信号(is_signal 返回 3)直接跳 finally_ip,且 make_error 不许包装它。三边合起来的效果 一样:最终 块照跑,捕获 接不住。

Windows 上会按 GBK 解码成乱码(与 M23 的源码 stdin 同一坑);管道输入还会 自己回显一遍命令,否则记录里读不出「哪句是用户敲的」。

类(参数)(进 初始化)都会经 UserFunction.__call__;那里按调用方递进来的 _pending_call_site 登记一帧,于是「我在哪一层、从哪一行被调进来」都显示得对。 只有「可能进基石函数」的那几类调用才更新调用点——否则回调体里的一句 打印(x) 会把调用点改成它自己,第二次回调进来时栈上显示上一句 打印 的行号(踩到过)。 C VM 侧同理由 _cb_call 记 _pending_call_site,重入建帧时用它 (jsvm_call_function 多了两个参数)。

——C 侧的名字表只收程序用到的名字,树遍历 / Python VM 那边把整套内建都放进来。 写数字会让三个执行器的记录对不上,而这数字对用户没有意义。

五、拿真实项目试过

examples/projects/基石自述站/生成.jsh(用基石写的静态站生成器,读仓库真数据 生成一套介绍基石的网页)就是 B5 的靶子:在它上面断点、单步、看变量、 看调用栈都正常,而且三个执行器跑出来的产物逐字节相同 (tests/test_m39_selfsite.py)。

六、编辑器里调试:jishi dap(M43)

编辑器(VS Code 等)通过 Debug Adapter Protocol 跟调试适配器对话。 jishi dap 就是那个适配器,它包的还是上面这套会话——命令行 jishi 调试 与编辑器两条入口共用同一个 _Session,所以断点判定、单步语义、变量渲染、 出错现场这些东西只写了一遍。

支持的请求与事件

请求说明
initialize / configurationDone握手;能力声明里如实写支持的与不支持的
launch参数:program(脚本路径)、stopOnEntry、执行器
setBreakpoints替换语义(这次给的集合就是全部);停不住的行回 verified: false + 原因
threads / stackTrace / scopes / variables栈与变量;frameId 指向调用栈的某一帧
evaluate在暂停处求值(监视面板与鼠标悬停)
continue / next / stepIn / stepOut / pause继续与单步;pause 在下一个语句边界生效
disconnect / terminate收场(程序还在跑时会先请它停下,走「退出」那条路跑完 最终 块)

事件:initialized、stopped(reason = entry / breakpoint / step / pause)、 continued、output(程序输出走 stdout 分类,调试器自己的话走 console)、 exited、terminated。

编辑器里不打命令行的寒暄:会话有一个 interactive 开关,调试适配器传 False, 于是「(输入「帮助」看命令)」这类只对终端有意义的提示不会混进调试控制台 (混进去会让人以为那是程序打印的)。停在哪一行、暂停原因这些有信息价值的 仍然照打——它们和命令行版逐字相同。

线程模型(改这个模块前必读)

主线程    DAP 消息循环:读请求、应答、发事件
脚本线程  跑用户程序;在断点处暂停时**阻塞**在命令队列上等命令

「主线程能直接读调试器状态」之所以安全:暂停期间脚本线程只打印与读命令 (_Session._pause 里就这两件事),不碰执行状态,所以 _live_chain() / _vars_of() 读到的是一份静止的快照。stopped 事件也因此在暂停判定一成立 就发出去(_Session.on_pause 钩子)——编辑器收到就来问栈,那时数据已经定了。

两个容易踩的点:

(不发 continued、不进队列)。入了队会攒下来,等下次命中断点时被立刻吃掉 ——表现就是「断点失灵」,极难排查。

适配器就成了僵尸进程。所以脚本跑完后起一个 5 秒的看门狗,到点自己退出。

三个执行器的差异

断点、单步、变量、调用栈逐项一致(tests/test_m43_dap.py 里三路对拍)。 唯一差异是 C 虚拟机不支持「在暂停处求值」:那边没有「在任意帧上求一个 表达式」的入口。适配器会回一条带原因的 error 并给替代方案(改用 vm 执行器), 不给错值——与命令行版的口径一致(_can_eval / _eval_expr 里写着原因)。

在编辑器里手工验一遍

打开一个 .jsh → 行号左边点断点(F9)→ F5 选「基石调试」→ 停在断点后看 左侧「变量」「调用栈」面板、F10 下一步、在「监视」面板里加表达式。 配置与安装方式见 editors/vscode-jishi/README.md。