运行时错误目前只能告诉你"除零错误",然后进程就没了。调试器停在一个机器地址上,对照的是 LLVM IR 甚至汇编,读者必须自己倒推这条指令对应源文件的哪一行。编译器花了十几篇功夫把 Sprout 源码翻译成机器码,却在出错的时候把来路全丢了。这一篇补上这条回路:从词法器的 Span 开始,让位置信息穿透 AST、HIR、SSA、LLVM IR,最终写进目标文件的 DWARF 段,使调试器和运行时错误处理器都能把机器地址映射回 sum.spr:5:12

位置信息的传递链

词法器扫描源文件时,每个 Token 已经携带了 Span——文件 ID、起始字节偏移、结束字节偏移。解析器把 Token 组装成 AST 节点,每个节点继承或合并子节点的 Span。名称解析和类型检查产生 Typed HIR,Span 仍然挂在每个表达式和语句上。

从 HIR 降低到 CFG/SSA 时,每条 SSA 指令也要保留原始 Span。一条 add %1, %2 -> %3 可能来自 sum = sum + i,它的 Span 指向源文件中 + 运算符的位置。这个信息在 SSA 层面只是一个附带字段,不参与优化判断,但不能丢。

到了 LLVM IR 生成阶段,Span 要转换成 LLVM 的调试元数据格式。LLVM 的做法是在每条指令上附加 !dbg 元数据引用,指向一个 DILocation 节点,节点里记录行号、列号和所属函数。LLVM 后端在生成目标文件时,把这些信息编码进 DWARF 格式的 .debug_info.debug_line 段。调试器读取这些段,就能把任意机器地址翻译回源文件位置。

整条链路:

1
2
3
4
5
6
7
Span (lexer)
→ AST node span
→ HIR node span
→ SSA instruction span
→ LLVM IR !dbg metadata (DILocation)
→ DWARF .debug_line
→ debugger / runtime error handler

任何一层丢了位置,后面就断了。最常见的问题是 SSA 变换合并或移动了指令却没有保留原始 Span——常量折叠把两条指令合成一条时,应该保留其中一个的位置;死代码删除直接移除指令,不需要处理;指令移动(比如循环不变量外提)需要决定保留哪个位置。我们的策略是:合并时保留第一个操作数的位置,移动时保留原始位置。

LLVM 调试元数据

LLVM IR 的调试信息由三类主要节点组成。

DICompileUnit 描述整个编译单元——源文件名、语言标识、生产者字符串、优化级别。一个 .ll 文件通常对应一个 DICompileUnit

DISubprogram 描述一个函数。它记录函数名、链接名、所在文件、起始行号、函数类型签名,以及是否为定义(isDefinition)。每个 LLVM Function 通过 !dbg 元数据引用自己的 DISubprogram

DILocation 描述一个源码位置,包含行号、列号和所属的 DISubprogram。每条 LLVM 指令可以通过 !dbg 引用一个 DILocation

sum_to 函数为例,生成的 LLVM IR 片段大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
define i64 @sprout_sum_to(i64 %n) !dbg !5 {
entry:
%sum = alloca i64, !dbg !10
store i64 0, ptr %sum, !dbg !10
; ...
%add = add i64 %sum.load, %i.load, !dbg !14
; ...
}

!5 = distinct !DISubprogram(name: "sum_to", file: !3,
line: 1, type: !6, isDefinition: true, unit: !0)
!10 = !DILocation(line: 2, column: 5, scope: !5)
!14 = !DILocation(line: 5, column: 16, scope: !5)

!dbg !14 表示这条 add 指令对应源文件第 5 行第 16 列。调试器在这条指令对应的机器地址处停下时,就能报出正确的源码位置。

从 SSA 到 LLVM IR 时附加调试信息

代码生成器在翻译每条 SSA 指令时,需要做两件额外的事。

第一,在函数开头创建 DISubprogram。函数的 Span 提供了文件和起始行号,函数签名提供了参数类型。把这些信息填进 DISubprogram,再把它关联到 LLVM Function

第二,在翻译每条 SSA 指令时,把指令的 Span 转换成 DILocation。Span 里的字节偏移需要转换成行号和列号——这个转换依赖源文件的换行符位置表,词法器在扫描时已经建好了。拿到行号和列号后,构造 DILocation 并附加到 LLVM 指令上。

用伪代码表示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fn emit_instruction(&mut self, inst: &SsaInst) {
let llvm_inst = match inst.op {
SsaOp::Add(l, r) => self.builder.build_add(l, r),
SsaOp::Sub(l, r) => self.builder.build_sub(l, r),
// ...
};
// 附加调试位置
if let Some(span) = inst.span {
let (line, col) = self.source_map.line_col(span.start);
let loc = self.di_builder.create_location(
line, col, self.current_subprogram
);
llvm_inst.set_debug_loc(loc);
}
}

如果某条指令没有合理的源码位置(比如编译器自己插入的 phi 消解代码),可以不附加 !dbg,或者继承前一条指令的位置。LLVM 对缺少 !dbg 的指令不会报错,但调试器在这些位置会显示"未知源码"。

还需要在模块级别创建 DICompileUnit 并设置 DWARF 版本。LLVM 默认生成 DWARF 4,如果目标平台的工具链支持 DWARF 5 也可以切换,但区别不影响这里的讨论。

DWARF 格式基础

DWARF 是 ELF 目标文件中存储调试信息的标准格式。它不是 LLVM 特有的,GCC、Clang 和其他编译器都用它。DWARF 标准 定义了一组段(section)和编码规则。

对源码映射最重要的是两个段。

.debug_info 段包含编译单元、函数、变量、类型等条目的树形结构。每个函数条目(DW_TAG_subprogram)记录函数名、文件、行号、地址范围。每个局部变量条目(DW_TAG_variable)记录变量名、类型和存储位置(寄存器或栈偏移)。

.debug_line 段包含行号程序(line number program)。这是一个紧凑的字节码序列,描述机器地址和源码行号之间的对应关系。调试器执行这段字节码,就能建立一张"地址 → 文件:行:列"的映射表。当你在 GDB 里输入 list 看到源码,或者收到 segfault 时看到行号,背后都是这张表在工作。

要让调试器的 print 命令能够查看局部变量,除了 DISubprogramDILocation 外,还需要为每个局部变量生成 DILocalVariable 元数据,并在变量定义处插入 @llvm.dbg.declare@llvm.dbg.value 内建调用。dbg.declare 将一个 alloca 地址与调试信息关联;dbg.value 用于 SSA 值。缺少这一步,调试器虽然能按行断点,但 print x 会报 ‘optimized out’。

LLVM 后端负责把 DILocation 元数据编码成 .debug_line 中的行号程序条目。我们不需要手写 DWARF 字节码——这是 LLVM 的活。我们只需要确保 LLVM IR 中的 !dbg 元数据正确反映源码位置。

llvm-dwarfdump 可以检查生成的 DWARF 内容:

1
2
sproutc build programs/sum.spr -o sum -O0
llvm-dwarfdump --debug-line sum

输出会列出地址和对应的文件、行号、列号。如果某一段地址没有对应的行号条目,说明那些指令缺少 !dbg 元数据。

运行时错误携带源码位置

第 15 篇实现了数组边界检查,第 07 篇约定了除零为运行时错误。这些检查在 SSA 层面会生成条件分支:如果除数为零,跳到错误处理块。错误处理块调用运行时函数终止程序。

之前的做法可能是简单地调用 abort() 或者打印一个固定字符串。现在有了源码位置,可以做得更好。

错误处理块知道触发错误的指令的 Span。在生成 LLVM IR 时,把文件名、行号、列号作为常量参数传给运行时错误函数:

1
2
3
4
5
6
7
8
9
; 除零检查失败时跳到这里
div_by_zero:
call void @sprout_runtime_error(
ptr @.str.sum.spr, ; 文件名
i64 5, ; 行号
i64 12, ; 列号
ptr @.str.div_zero ; 错误消息
)
unreachable

运行时函数的实现很直接:

1
2
3
4
5
6
void sprout_runtime_error(const char *file, int64_t line,
int64_t col, const char *msg) {
fprintf(stderr, "runtime error: %s at %s:%ld:%ld\n",
msg, file, line, col);
exit(101);
}

现在运行一个有除零错误的程序:

1
2
$ ./sum_bad
runtime error: divide by zero at sum.spr:5:12

用户立刻知道去看 sum.spr 的第 5 行第 12 列。数组越界也一样:

1
runtime error: index out of bounds at data.spr:12:8

这个方案和 DWARF 调试信息是互补的。DWARF 让调试器能映射地址,运行时错误消息让不使用调试器的用户也能定位问题。两者的数据来源相同——都是 SSA 指令上的 Span。

有一个实现细节需要注意:错误消息中的文件名字符串。每个错误处理块需要引用文件名的全局常量,一个源文件只需要生成一个这样的常量。行号和列号是整数立即数,没有额外开销。如果担心二进制体积,可以用一个全局文件名表加索引的方式压缩,但对教学编译器来说,直接嵌入文件名字符串足够清晰。

另一个设计选择是退出码。上面用了 exit(101) 而不是 exit(1)。把运行时错误的退出码和正常的非零返回区分开来,方便测试框架判断程序是正常失败还是运行时错误。具体数值可以约定,比如 101 表示除零,102 表示越界,103 表示整数溢出。测试用例可以同时检查 stderr 输出和退出码。

调试器实测

编译一个简单程序,关闭优化,然后用 GDB 或 LLDB 调试。

1
2
sproutc build programs/sum.spr -o sum -O0
gdb ./sum

在 GDB 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(gdb) break sum_to
Breakpoint 1 at 0x401020: file sum.spr, line 1.
(gdb) run
Starting program: ./sum

Breakpoint 1, sum_to (n=10) at sum.spr:1
1 fn sum_to(n: i64) -> i64 {
(gdb) next
2 let mut sum: i64 = 0;
(gdb) next
3 let mut i: i64 = 1;
(gdb) print sum
$1 = 0
(gdb) continue
Continuing.
55
[Inferior 1 (process 12345) exited normally]

GDB 能够:

  • 在 Sprout 函数名上设断点,因为 DISubprogram 记录了函数名和地址范围
  • 显示当前停在源文件的哪一行,因为 .debug_line 提供了地址到行号的映射
  • next 按源码行单步执行
  • print 查看局部变量的值,因为 .debug_info 记录了变量的存储位置

这要求 DWARF 信息里的变量位置描述正确。在 -O0 下,局部变量通常在栈上有固定位置,LLVM 会生成 DW_AT_location 属性指向栈帧中的偏移。调试器根据当前栈指针和这个偏移就能读出变量值。

LLDB 的操作类似,命令稍有不同:

1
2
3
4
(lldb) breakpoint set --name sum_to
(lldb) run
(lldb) frame variable sum
(int64_t) sum = 0

优化对调试信息的影响

切换到 -O2 再试一次:

1
2
sproutc build programs/sum.spr -o sum -O2
gdb ./sum
1
2
3
4
5
6
7
8
(gdb) break sum_to
Breakpoint 1 at 0x401010: file sum.spr, line 1.
(gdb) run
Breakpoint 1, sum_to (n=10) at sum.spr:1
(gdb) print sum
$1 = <optimized out>
(gdb) print i
$2 = <optimized out>

变量 sumi 被优化掉了——它们可能只存在于寄存器中,而且循环可能被完全替换成了一个公式。调试器知道这些变量在源码中存在(DWARF 里有条目),但找不到它们当前的值,所以显示 <optimized out>

单步执行时,行号跳跃也会变得不连续。优化器重排了指令,一条机器指令可能对应多个源码行,或者多条机器指令对应同一行。.debug_line 尽力记录了这种对应关系,但从用户视角看,执行顺序不再符合源码的书写顺序。

这是所有编译器的通用现象,不是 Sprout 的特殊问题。GCC 和 Clang 编译 C 代码在 -O2 下也会出现同样的情况。调试优化后的代码本身是一个复杂课题,DWARF 5 引入了一些改进(如 DW_AT_location 的范围列表可以描述变量在不同地址范围内的不同存储位置),但完美调试与高度优化之间始终存在张力。

对 Sprout 编译器来说,保证 -O0 下调试体验正确是底线要求。-O2 下行号映射应该基本正确(能定位到正确的函数和大致的代码区域),变量可能不可见是预期行为。

还有一种中间地带:-O1 或带 -Og(optimize for debugging)。Clang 和 GCC 支持 -Og,它会做一部分不影响调试体验的优化,比如简单的常量折叠和死代码删除,但不做寄存器重分配和循环变换。我们的编译器可以考虑类似策略:自制优化 pass 各自声明是否保持调试友好,-Og 只启用标记为安全的那些 pass。不过这是后续改进,当前先把 -O0-O2 两个极端跑通。

DWARF 信息对二进制体积的影响也需要了解。调试段不加载到运行时内存,但它们会显著增大磁盘上的文件大小——一个几 KB 的程序加上调试信息可能膨胀到几十 KB。strip 命令可以移除调试段,发布时通常会做这一步。也可以把调试信息分离到独立的 .dSYM 目录(macOS)或 .debug 文件(Linux),让可执行文件保持精简,调试器按需加载。

练习

  1. -O0 编译 sum.spr,在调试器中单步执行 sum_to 函数,打印循环中 sumi 的值。确认每一步的值与手算一致。

  2. -O2 编译同一个文件,重复上述操作。记录哪些变量显示为 <optimized out>,单步执行时行号是否连续。对比两次的机器指令数量(用 disassemble 命令)。

  3. llvm-dwarfdump --debug-line 分别检查 -O0-O2 产物的行号表。找出两个版本中同一源码行对应的机器地址范围有何不同。

上一篇:19 - 多文件模块、ABI 与链接。下一篇:21 - 差分、属性与模糊测试