14.5 调试信息与 Source Map
机器码执行地址、寄存器与内存;开发者思考的却是文件、行、函数、变量与类型。调试信息(debug information)保存这两个世界之间的映射。原生工具链常在 ELF/Mach-O 中使用 DWARF,在 PE/COFF 中使用 CodeView/PDB;JavaScript 与其他生成文本生态常使用 source map。格式虽不同,它们都在记录普通指令不再携带的来源信息。
调试元数据不是一张简单的反向查询表。调试器必须回答多个独立问题:这个程序计数器(program counter)对应哪个源码位置?它位于哪个函数与词法作用域(lexical scope)?函数是否被内联(inline)?变量 x 此刻在哪里?怎样重建调用者的栈帧(caller frame)?应使用什么类型/布局展示一个值?
地址范围、line program 与变量位置
原生编译器会发出行表(line table),把地址范围映射到源文件、行号,通常还包括列号。DWARF 不是为每条指令重复完整记录,而是把映射压缩成小型状态机程序(state-machine program)。多条机器指令可对应同一行;一条指令也可能合并多个表达式;优化后的指令还可能远离原始源码顺序。
变量不仅需要名字,还需要位置描述(location description)。寄存器分配前,total 可能是 SSA 值;之后先在 rax 寄存器中,再进入栈槽(stack slot),最后因折叠或删除而不再有可恢复的位置。位置列表(location list)会把不同的程序计数器范围与"寄存器 3"、"CFA 减 16"、"常量 7"等表达式关联起来。显示 <optimized out> 比展示过期值更诚实。
内联(inlining)增加了一个维度:程序计数器在物理上位于 sum 函数,逻辑上却可能表示对另一个文件中 add 函数的调用。调试元数据会记录抽象函数(abstract function)与一个或多个内联调用点实例(inline call-site instance),让调试器展示虚拟调用栈。.eh_frame 等栈展开信息(unwind information)则描述如何恢复调用者的规范帧地址(canonical frame address)、已保存的寄存器和返回地址,即使函数省略了帧指针也能回溯调用栈。
优化会改变“可观察”的内容
在 -O0 下,编译器常保留直观的语句顺序(statement order)与稳定的栈存储位置(stack home),让单步调试容易理解,但代码较慢。更高优化下,公共子表达式消除(common-subexpression elimination)、指令调度(instruction scheduling)、尾部复制(tail duplication)、循环变换与内联会打破"执行按文本顺序逐条访问语句"的幻觉。变量可能在合流路径上具有不同的值,也可能被拆成位于多个位置的片段(pieces)。
因此,可靠的优化调试支持(optimized-debug support)必须贯穿流水线设计。IR 操作携带源码位置;变换按明确规则选择保留、合并或丢弃位置;值追踪(value tracking)跟踪重要变量经过复制/替换的变化;后端在寄存器分配后发出范围准确的位置描述。错误的元数据比缺失的元数据更糟,因为它会自信地误导诊断。编译器应以调试器脚本测试:设置断点、单步执行、检查变量、打印内联调用栈、跨异常展开,并对照预期源码位置。
用 Source Map 追踪转换文本
Source map 用于将生成的文本位置映射回原始源文件(source)。常见的 version 3 source map 包含 sources、可选的内嵌源内容 sourcesContent、变量名列表 names 以及紧凑的映射关系 mappings。mappings 按生成的行(generated line)组织,并使用 Base64 VLQ 编码相对偏移量(delta)。一个片段(segment)可把生成的列(generated column)连接到原始文件索引(source index)、原始行/列(original line/column)与可选的变量名索引(name index)。
假设 TypeScript 先转译成 JavaScript,再打包(bundle),最后压缩混淆(minify)。每一阶段都可产生输出到输入的映射表(output-to-input map);构建流水线必须组合这些 maps,让生产环境堆栈中 bundle.js:1:8392 最终能够映射回 parser.ts:74:11,而不是只停留在中间文件。压缩混淆后整个 bundle 可能只有一行,因此列级别映射(column mapping)极其重要。names 表可在 parseExpression 变成 a 后帮助还原名称,但会增加文件体积(payload)。
Map 可以以内联(inline)形式存在、作为相邻的 .map 文件公开,也可以不向用户发布,只上传到错误报告服务。最后一种方式在不公开源码内容的同时,依然支持在生产环境进行崩溃堆栈解析与符号化(symbolication),但仍要控制访问权限与保留期,因为 map 可能包含原始路径、符号名称或完整的源文件内容。原生部署也有类似的分离:生产环境存放裁剪后的二进制文件(stripped binary),而在受保护的符号服务器(symbol server)中保存索引后的调试伴侣数据(debug companion)。两者都应当应用构建 ID(build ID)或内容哈希(content hash),把代码与完全匹配的符号信息配对。近似匹配非常危险:每个地址都可能解析到看似合理却完全错误的行。
端到端的设计原则很简单:把出处(provenance)当作具有模式(schema)、兼容要求、隐私策略与测试验证的编译器输出。验证确定性 ID(deterministic ID)、路径重映射(path remapping)、分离调试打包(split-debug packaging)、map 组合(map composition)、优化单步执行(optimized stepping)、崩溃符号化(crash symbolication)以及有意缺失元数据(metadata)时的行为。