本文记录了我在开发中文编译器时遇到的一个隐蔽BUG:
取地址 函数名无法生成正确的汇编代码。这个BUG让我第一次深入理解了x86-64的RIP相对寻址机制。
版权声明:本文为原创技术文章,版权归作者所有。文中涉及的编译器设计思路和调试方法论仅供参考学习,核心源码未公开。如需引用,请注明出处。
一、故事开始
2026年6月6日,距离修复第一个BUG(全局字符串地址失效)刚过了一天。
我正在为编译器添加函数指针功能。在操作系统中,函数指针非常重要——中断处理需要通过函数指针来调用对应的处理函数。
我写了一段测试代码:
定义 函数 中断处理() {
调用 VGA_输出字符串("中断触发")
}
定义 整数 函数指针 为 取地址 中断处理
这段代码的逻辑很简单:
- 定义一个函数
中断处理 - 用
取地址获取这个函数的地址,存入变量函数指针
编译时没有报错。但运行时,通过函数指针调用函数后,系统直接崩溃了。
二、什么是函数指针?
在理解这个BUG之前,先说说函数指针是什么。
函数的内存模型
在x86-64中,函数编译后会生成一段机器码,存储在内存中的某个地址:
内存地址 机器码 汇编
0x100000: 55 push rbp
0x100001: 48 89 E5 mov rbp, rsp
0x100004: 48 83 EC 20 sub rsp, 32
0x100008: ...
函数名本质上就是一个地址标签,指向这段机器码的起始位置。
函数指针的用途
函数指针是一个变量,存储的是函数的地址:
函数指针变量 = 0x100000 ← 存储的是函数的入口地址
调用方式:
CALL [函数指针变量] → 跳转到0x100000执行
在操作系统开发中,函数指针的核心应用场景包括:
- 中断处理:中断向量表中存储的是函数指针,中断发生时跳转到对应函数
- 系统调用:系统调用表通过函数指针分发到不同的处理函数
- 回调函数:将函数作为参数传递给另一个函数
- 驱动程序:驱动接口通过函数指针实现多态
三、排查过程
第一步:确认现象
我通过串口输出检查了函数指针的值:
函数指针的值 = 0x00000000
地址是0!这显然是错的。函数的地址应该是一个有效的内存地址,不可能是0。
第二步:检查汇编代码
我查看了编译器生成的汇编代码:
; 定义 函数 中断处理
中断处理:
push rbp
mov rbp, rsp
...
; 定义 整数 函数指针 为 取地址 中断处理
MOV RAX, 0 ; ← 这里!直接把0赋给了RAX
MOV [RSP - 8], RAX ; 存入变量
问题找到了。编译器在处理取地址 中断处理时,生成了MOV RAX, 0——它把函数地址当成了数字0。
第三步:深入分析
为什么编译器会生成MOV RAX, 0?
我追踪了编译器的代码生成逻辑:
1. 解析"取地址 中断处理"
2. 识别出"中断处理"是一个标识符
3. 在符号表中查找"中断处理"
4. 没找到(因为函数名不在普通变量符号表中)
5. 默认返回0
编译器不知道中断处理是一个函数名,所以找不到它的地址,默认返回了0。
四、什么是RIP相对寻址?
在x86-64中,获取函数地址的正确方式是使用RIP相对寻址。
RIP寄存器
RIP(Instruction Pointer)是x86-64的程序计数器,指向下一条要执行的指令的地址。
RIP相对寻址的原理
RIP相对寻址是指:以当前RIP的值为基准,加上一个偏移量,得到目标地址。
LEA RAX, [RIP + offset]
这条指令的意思是:
- 取当前RIP的值(下一条指令的地址)
- 加上offset
- 结果放入RAX
为什么函数地址需要RIP相对寻址?
在x86-64中,函数编译后的地址在链接前是不确定的。编译器只知道函数相对于当前指令的偏移量。
当前指令地址:0x100000
函数入口地址:0x100050
偏移量:0x100050 - (0x100000 + 7) = 0x49
LEA RAX, [RIP + 0x49]
→ RAX = 0x100000 + 7 + 0x49 = 0x100050
这样无论代码被加载到哪个地址,RIP相对寻址都能正确找到函数。
与x86的区别
在32位x86中,通常使用绝对地址:
MOV EAX, 中断处理 ; 直接使用绝对地址
但在64位模式下,绝对地址有4GB的限制,而且不符合位置无关代码(PIC)的要求。所以x86-64推荐使用RIP相对寻址。
五、修复方案
核心思路
编译器需要:
- 预收集所有函数名
- 在处理
取地址 函数名时,生成LEA RAX, [RIP + 函数名]指令
具体实现思路
修复分为两个阶段:
阶段一:预收集函数名
在编译代码之前,先扫描一遍所有函数定义,收集函数名:
扫描所有语句:
如果是函数定义:
记录函数名 → 加入函数名集合
阶段二:生成正确的代码
在处理取地址表达式时,检查标识符是否是函数名:
解析"取地址 标识符":
如果标识符在函数名集合中:
生成 LEA RAX, [RIP + 标识符]
否则:
按普通变量处理
修复后的代码生成
; 定义 整数 函数指针 为 取地址 中断处理
LEA RAX, [RIP + 中断处理] ; ← RIP相对寻址,获取函数地址
MOV [RSP - 8], RAX ; 存入变量
六、验证
修复后,我重新编译运行测试代码:
定义 函数 中断处理() {
调用 VGA_输出字符串("中断触发")
}
定义 整数 函数指针 为 取地址 中断处理
通过串口检查函数指针的值:
函数指针的值 = 0x10xxxx ← 有效的内存地址
然后通过函数指针调用函数:
定义 整数 返回值 为 调用指针(函数指针)
屏幕上正确显示了:
中断触发
七、这个BUG在后续开发中的影响
这个修复看似简单,但它在后续开发中发挥了关键作用:
1. 中断处理系统
在D+E阶段,我需要把中断处理函数的地址写入IDT(中断描述符表):
; 伪代码
定义 整数 处理函数地址 为 取地址 处理_中断
调用 IDT_设置入口(32, 处理函数地址)
如果取地址不能正确工作,整个中断系统就无法运行。
2. boot.asm与kernel的桥梁
在F阶段,我把处理_中断函数的地址写到了固定地址0x50000:
; kernel启动时
定义 整数 函数地址 为 取地址 处理_中断
调用 内存_写(0x50000, 函数地址)
boot.asm中的中断stub通过call [0x50000]来调用kernel的中断处理函数。
这个设计成为了整个系统的基础架构。
3. 程序启动器
在H阶段,run命令加载用户程序时,也需要通过函数指针跳转到用户程序的入口地址。
八、经验总结
1. 符号表要区分变量和函数
编译器的符号表需要区分不同类型的标识符:
- 局部变量 → 栈偏移
- 全局变量 → 固定地址
- 函数名 → RIP相对地址
- 字符串常量 → 常量区地址
每种标识符的地址获取方式不同,不能混为一谈。
2. x86-64推荐RIP相对寻址
在64位模式下,RIP相对寻址是获取代码地址的标准方式。它有以下优点:
- 支持位置无关代码(PIC)
- 无4GB地址限制
- 指令更短,效率更高
3. 预扫描是编译器的常见技术
很多编译器问题都可以通过预扫描解决:
- 预收集函数名 → 支持
取地址 函数名 - 预收集全局变量 → 支持全局变量初始化
- 预收集字符串 → 支持全局字符串分配
本项目的中文编译器大量使用了预扫描技术,这是解决前向引用问题的利器。
4. 串口是裸机调试的眼睛
在裸机环境下没有GDB,串口输出几乎是最重要的调试手段。通过串口打印变量值,可以快速定位问题。
九、下一步计划
修复取地址功能后,编译器的能力边界又扩展了一步。接下来的计划:
- else_body预扫描修复:else分支中的变量分配问题
- 字符串比较函数修复:双0终止判断的陷阱
- 裸机模式全局变量初始化:没有PE加载器时的变量初始化
这些内容将在后续文章中详细介绍。
版权声明:本文为原创内容,首发于知乎。文中涉及的编译器设计思路和调试方法论仅供参考学习,核心源码未公开。如需引用,请注明出处。