取地址函数名:RIP相对寻址缺失之谜

11 阅读8分钟

本文记录了我在开发中文编译器时遇到的一个隐蔽BUG:取地址 函数名无法生成正确的汇编代码。这个BUG让我第一次深入理解了x86-64的RIP相对寻址机制。


版权声明:本文为原创技术文章,版权归作者所有。文中涉及的编译器设计思路和调试方法论仅供参考学习,核心源码未公开。如需引用,请注明出处。


一、故事开始

2026年6月6日,距离修复第一个BUG(全局字符串地址失效)刚过了一天。

我正在为编译器添加函数指针功能。在操作系统中,函数指针非常重要——中断处理需要通过函数指针来调用对应的处理函数。

我写了一段测试代码:

定义 函数 中断处理() {
    调用 VGA_输出字符串("中断触发")
}

定义 整数 函数指针 为 取地址 中断处理

这段代码的逻辑很简单:

  1. 定义一个函数中断处理
  2. 取地址获取这个函数的地址,存入变量函数指针

编译时没有报错。但运行时,通过函数指针调用函数后,系统直接崩溃了。


二、什么是函数指针?

在理解这个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执行

在操作系统开发中,函数指针的核心应用场景包括:

  1. 中断处理:中断向量表中存储的是函数指针,中断发生时跳转到对应函数
  2. 系统调用:系统调用表通过函数指针分发到不同的处理函数
  3. 回调函数:将函数作为参数传递给另一个函数
  4. 驱动程序:驱动接口通过函数指针实现多态

三、排查过程

第一步:确认现象

我通过串口输出检查了函数指针的值:

函数指针的值 = 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]

这条指令的意思是:

  1. 取当前RIP的值(下一条指令的地址)
  2. 加上offset
  3. 结果放入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相对寻址。


五、修复方案

核心思路

编译器需要:

  1. 预收集所有函数名
  2. 在处理取地址 函数名时,生成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,串口输出几乎是最重要的调试手段。通过串口打印变量值,可以快速定位问题。


九、下一步计划

修复取地址功能后,编译器的能力边界又扩展了一步。接下来的计划:

  1. else_body预扫描修复:else分支中的变量分配问题
  2. 字符串比较函数修复:双0终止判断的陷阱
  3. 裸机模式全局变量初始化:没有PE加载器时的变量初始化

这些内容将在后续文章中详细介绍。


版权声明:本文为原创内容,首发于知乎。文中涉及的编译器设计思路和调试方法论仅供参考学习,核心源码未公开。如需引用,请注明出处。