本文记录了我在开发中文编译器时遇到的一个隐蔽BUG:
else分支中的变量和字符串不被预扫描,导致栈空间分配不足,变量偏移计算错误。这个BUG让我第一次深刻理解了编译器的预扫描机制。
版权声明:本文为原创技术文章,版权归作者所有。文中涉及的编译器设计思路和调试方法论仅供参考学习,核心源码未公开。如需引用,请注明出处。
一、故事开始
2026年6月8日,距离修复全局字符串地址失效BUG刚过了3天。
我正在测试编译器的条件判断功能。写了一段代码:
定义 函数 主() {
定义 整数 条件 为 1
如果 (条件 等于 1) {
调用 VGA_输出字符串("条件为真")
} 否则 {
调用 VGA_输出字符串("条件为假")
}
}
这段代码的逻辑很简单:
- 定义一个条件变量,值为1
- 如果条件为1,输出"条件为真"
- 否则,输出"条件为假"
编译时没有报错。运行时,条件等于1为真,所以应该输出"条件为真"。
结果呢?程序直接崩溃了。
二、什么是预扫描?
在理解这个BUG之前,先说说编译器的预扫描机制。
编译器的代码生成流程
我的编译器生成代码的流程分为三个阶段:
1. 预扫描阶段
↓
2. 栈空间分配阶段
↓
3. 代码生成阶段
预扫描的作用
预扫描的目的是统计函数中需要分配的所有变量和数组,以便在进入函数时一次性分配足够的栈空间。
预扫描过程:
扫描函数体中的所有语句:
如果是变量定义 → 计数+1
如果是数组定义 → 计算数组大小,累加到数组栈空间
如果是字符串定义 → 计算字符串字节数,累加到数组栈空间
如果是条件语句/循环语句 → 递归扫描分支/循环体
为什么需要预扫描?
在x86-64中,函数进入时会一次性分配所有需要的栈空间:
主:
PUSH RBP
MOV RBP, RSP
SUB RSP, 100 ; ← 一次性分配100字节栈空间
; 函数体使用 [RSP + offset] 访问变量
如果预扫描不完整,分配的栈空间就会不足,导致变量覆盖或栈溢出。
三、排查过程
第一步:确认现象
程序崩溃了,说明某个地方出了严重问题。我通过串口输出检查了执行流程:
[DEBUG] 进入主函数
[DEBUG] 定义条件变量
[DEBUG] 如果条件判断
[DEBUG] 条件为真,进入if分支
[DEBUG] 调用VGA_输出字符串
[崩溃]
崩溃发生在调用VGA_输出字符串("条件为真")时。
第二步:检查汇编代码
我查看了编译器生成的汇编代码:
主:
PUSH RBP
MOV RBP, RSP
SUB RSP, 16 ; ← 只分配了16字节!
; 定义 整数 条件 为 1
MOV dword [RSP + 12], 1
; 如果条件等于1
MOV RAX, [RSP + 12]
CMP RAX, 1
JNE else_label
; if分支:调用 VGA_输出字符串("条件为真")
MOV RDI, [RSP + 0] ; ← 字符串地址
CALL VGA_输出字符串
else_label:
; else分支:调用 VGA_输出字符串("条件为假")
MOV RDI, [RSP + 0] ; ← 又是同一个地址!
CALL VGA_输出字符串
ADD RSP, 16
POP RBP
RET
问题找到了!编译器只分配了16字节栈空间,但两个字符串("条件为真"和"条件为假")需要更多空间。而且两个字符串的地址都是[RSP + 0]——它们被分配到了同一个位置!
第三步:追踪预扫描逻辑
我查看了编译器的_pre_scan函数:
def _pre_scan(self, stmts: list):
array_stack = 0
var_count = 0
for stmt in stmts:
# ... 处理各种语句类型 ...
elif isinstance(stmt, IfStmt):
# 递归扫描条件体中的语句
sub_array, sub_vars = self._pre_scan(stmt.body)
array_stack += sub_array
var_count += sub_vars
# 注意:这里没有扫描else_body!
关键发现:_pre_scan函数只扫描了if_body(stmt.body),但没有扫描else_body!
四、为什么会漏扫?
这个BUG的根源在于编译器设计时的一个疏忽。
编译器开发者的思维误区
当我设计_pre_scan函数时,我的思考过程是这样的:
条件语句有两个分支:if和else
程序运行时只会执行其中一个分支
所以只需要为其中一个分支分配栈空间?
不对!栈空间是在函数进入时一次性分配的
编译器无法预测运行时会走哪个分支
所以必须为两个分支都分配栈空间
但我在实现时忘记了这个关键点。
栈空间分配的原则
栈空间分配遵循一个简单的原则:取所有分支中的最大值。
if分支需要: 字符串"条件为真" = 7字节 + 长度变量8字节 = 15字节
else分支需要: 字符串"条件为假" = 7字节 + 长度变量8字节 = 15字节
总共需要: max(15, 15) + 条件变量8字节 = 23字节
但编译器只分配了: 条件变量8字节 = 8字节(加上字符串长度变量8字节,共16字节)
注意:实际上编译器的分配逻辑中,字符串的字节数据放在数组区域,长度变量放在变量区域。但不管怎样,else分支的字符串数据没有被计入。
五、修复方案
核心思路
在_pre_scan函数中,处理IfStmt时,不仅要扫描if_body,还要扫描else_body。
具体实现
修复后的_pre_scan函数:
elif isinstance(stmt, (IfStmt, WhileStmt)):
# 递归扫描条件体中的语句
sub_array, sub_vars = self._pre_scan(stmt.body)
array_stack += sub_array
var_count += sub_vars
# 修复: 原代码漏扫 else_body,导致 else 分支中的字符串/变量
# 不被预扫描,栈空间分配不足,偏移计算错误
# (例如 REPL_包装输入 的所有字符串都在 else 分支中)
if isinstance(stmt, IfStmt) and stmt.else_body:
sub_array, sub_vars = self._pre_scan(stmt.else_body)
array_stack += sub_array
var_count += sub_vars
修复后的汇编代码
主:
PUSH RBP
MOV RBP, RSP
SUB RSP, 40 ; ← 正确分配了40字节!
; 定义 整数 条件 为 1
MOV dword [RSP + 36], 1
; 字符串初始化
; "条件为真"在 [RSP + 0]
MOV byte [RSP + 0], '条'
MOV byte [RSP + 3], '件'
...
; "条件为假"在 [RSP + 16]
MOV byte [RSP + 16], '条'
MOV byte [RSP + 19], '件'
...
; 如果条件等于1
MOV RAX, [RSP + 36]
CMP RAX, 1
JNE else_label
; if分支
MOV RDI, [RSP + 24] ; ← 正确的字符串地址
CALL VGA_输出字符串
else_label:
; else分支
MOV RDI, [RSP + 32] ; ← 正确的字符串地址
CALL VGA_输出字符串
ADD RSP, 40
POP RBP
RET
六、验证
修复后,我重新编译运行测试代码:
定义 函数 主() {
定义 整数 条件 为 1
如果 (条件 等于 1) {
调用 VGA_输出字符串("条件为真")
} 否则 {
调用 VGA_输出字符串("条件为假")
}
}
屏幕上正确显示了:
条件为真
我又测试了条件为假的情况:
定义 整数 条件 为 0
屏幕上显示:
条件为假
两个分支都能正确执行了!
七、这个BUG在后续开发中的影响
这个修复虽然看似简单,但它在后续开发中发挥了关键作用:
1. REPL交互功能
在B阶段开发REPL功能时,REPL_包装输入函数中有大量的条件判断,其中很多字符串定义都在else分支中:
定义 函数 REPL_包装输入() {
如果 (输入长度 大于 0) {
; ... 处理输入 ...
} 否则 {
调用 VGA_输出字符串("请输入命令")
; ... 其他字符串 ...
}
}
如果else_body没有被预扫描,REPL功能就无法正常工作。
2. 思考引擎
在C阶段开发思考引擎时,条件判断更加复杂,涉及多层嵌套的if-else语句。如果预扫描不完整,整个思考引擎就会崩溃。
3. 编译器的健壮性
这个修复让编译器更加健壮。后来我遇到了类似的问题——while循环体中的变量扫描,但因为有了这次经验,我很快就定位并修复了问题。
八、经验总结
1. 栈空间分配是保守的
栈空间分配遵循"最坏情况"原则:为所有可能执行的分支分配空间,取最大值。
if分支: 10字节
else分支: 20字节
总共分配: max(10, 20) = 20字节
即使程序运行时只走if分支(只需要10字节),栈空间也会分配20字节。这是为了保证程序的正确性。
2. 预扫描必须完整
预扫描是栈空间分配的基础,必须扫描所有可能执行的代码路径:
- if分支和else分支
- while循环体和循环外的代码
- 函数调用中的参数
- 嵌套的条件和循环
任何遗漏都会导致栈空间分配不足。
3. 测试要覆盖所有分支
测试条件判断时,必须测试所有分支:
- 测试if分支为真的情况
- 测试if分支为假的情况(else分支)
- 测试嵌套条件的所有组合
只有这样才能发现预扫描不完整的问题。
4. 注释是最好的文档
修复后的代码中有一段注释:
# 修复: 原代码漏扫 else_body,导致 else 分支中的字符串/变量
# 不被预扫描,栈空间分配不足,偏移计算错误
# (例如 REPL_包装输入 的所有字符串都在 else 分支中)
这段注释记录了BUG的原因、影响和具体例子。几个月后当我再次查看这段代码时,立刻就明白了这段代码的意义。
九、下一步计划
修复else_body预扫描后,编译器的能力边界又扩展了一步。接下来的计划:
- 字符串比较函数修复:双0终止判断的陷阱(BUG-002)
- Mini PE返回值修复:EAX还是ECX的秘密(BUG-E04)
- isalpha函数修复:下划线和数字不配当标识符?(BUG-E05)
这些内容将在后续文章中详细介绍。
版权声明:本文为原创内容,首发于知乎。文中涉及的编译器设计思路和调试方法论仅供参考学习,核心源码未公开。如需引用,请注明出处。
相关文章:
- 《栈帧上的幽灵:全局字符串地址为什么突然失效》
- 《取地址函数名:RIP相对寻址缺失之谜》
- 《双0终止的陷阱:字符串比较返回0的致命BUG》
- 《EAX还是ECX:Mini PE返回值的秘密》