在学习 riscv 架构的时候发现程序中的全局变量的访问是通过某个寄存器+偏移地址的方式来进行的。一直不清楚编译器编译时如何生成访问全局变量的代码,今天研究下 x86-64 架构上的一种常见的全局变量的访问方式。
使用如下代码进行研究测试:
#include <stdio.h>
#include <stdlib.h>
int running;
int running_two;
void bye(void)
{
running = 1;
running_two = 1;
}
int main(void)
{
bye();
exit(0);
}
上述代码中有两个未初始化的全局变量 running 与 running_two,这两个未初始化的全局变量将会被放到 .bss 段中。这里我将上述代码存储为 test.c 的源文件,对其进行编译。
gcc -c 生成可重定位目标文件
执行 gcc -c test.c 命令即可生成 test.o 文件。对 test.o 文件进行反汇编,找到 bye 函数的汇编码如下;
0000000000000000 <bye>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: c7 05 00 00 00 00 01 movl $0x1,0x0(%rip) # e <bye+0xe>
b: 00 00 00
e: c7 05 00 00 00 00 01 movl $0x1,0x0(%rip) # 18 <bye+0x18>
15: 00 00 00
18: 90 nop
19: 5d pop %rbp
1a: c3 retq
在上面的反汇编输出中,两个 movl 指令分别是访问全局变量 running 与 running_two 的过程。
0x0(%rip) 与 0x0(%rip) 对应的是两个全局变量的地址,这里的寻址方式应当是寄存器寻址。而全局变量地址的访问,以 rip (pc) 寄存器的值为基址,0x0 只是一个占位符,并不表示实际的位置,它将会在链接过程中确定下来。这种方式被称为 RIP-relative addressing。
查看重定位信息
执行 readelf 命令查看可重定位 section 的信息,输出如下:
[longyu@debian-10:23:02:13] tmp $ readelf -r test.o
重定位节 '.rela.text' at offset 0x278 contains 4 entries:
偏移量 信息 类型 符号值 符号名称 + 加数
000000000006 000800000002 R_X86_64_PC32 0000000000000004 running - 8
000000000010 000900000002 R_X86_64_PC32 0000000000000004 running_two - 8
R_X86_64_PC32 是重定位的类型,它被定义为 “S + A - P” 的形式。S、A、P 的函数如下:(摘录自Relocatable Global Data on x86 )
S: Represents the value of the symbol whose index resides in the relocation entry.
A: Represents the addend used to compute the value of the relocatable field.
P: Represents the place of the storage unit being relocated.
对于第一个可重定位项,S 表示 running 符号的地址,A 的值为 -8,表示相对重定位偏移量之后的8个字节的位置,P 表示被重定位内容的存储位置,在这里它的值就是 6。在上文中反汇编 bye 函数的指令中,我们可以发现 6 这个偏移量是位于第一个 movl $0x1,0x0(%rip)
语句中,它其实就是计算出的 32-bit 偏移地址存放的地址。
生成可执行目标文件
链接脚本中指定 .bss 段的起始位置,.bss 段的起始位置确定后,running 与 running_two 在 .bss 段中的地址也就固定了。
反汇编链接后生成的可执行目标文件中的 bye 函数,有如下信息:
0000000000001135 <bye>:
1135: 55 push %rbp
1136: 48 89 e5 mov %rsp,%rbp
1139: c7 05 f5 2e 00 00 01 movl $0x1,0x2ef5(%rip) # 4038 <running>
1140: 00 00 00
1143: c7 05 e7 2e 00 00 01 movl $0x1,0x2ee7(%rip) # 4034 <running_two>
114a: 00 00 00
114d: 90 nop
114e: 5d pop %rbp
114f: c3 retq
我们可以看到对 running 与 running_two 全局变量的访问被替换为了 rip 的值 + 偏移量的格式。
那这个偏移量是咋计算出来的呢?
其实用上文中我们提到过的 S + A -P 的公式就可以计算出这个偏移量。
running 全局变量 rip + addr 访问形式中,addr 使用下面的公式计算:
4038 -8 - 113b => 0x2ef5
这里的 4038 表示的是 running 的地址,它是 S,-8 表示 P 地址之后 8 个字节的位置,它是 A,113b 表示 32-bit 的偏移地址的存储位置, 它是 P。
S + A - P 就得到了 0x2ef5 这个偏移地址。由于机器是小端方式,低字节存储到低地址处,指令中存储的的就是 f5 2e.
RIP-relative addressing 中的 rip 体现在哪里?
上面的计算中没有体现 rip 的值。其实我们可以将上面的计算公式改为这样:4038 - (8 + 113b). 这里的 8 + 113b 就是 rip 指向的绝对地址,计算 running 符号与此地址的差值就能得到 rip + addr 的形式访问全局变量的 addr。
程序中段的内存布局
在这样的计算方式中, .bss 段位于高地址,text 段位于低地址,这就是常见的程序的内存布局。我们可以查看下面的图片,.text 段位于 low address。此图摘录自《APUE》。
我们也可以看出,.text 段与 .bss 段的距离是固定的,.data 段与 .bss 段的距离也是固定的。.data 与 .bss 中分配的变量的地址在编译期间就固定下来,尽管段的起始地址可能会变化,但是段间的偏移地址是固定的,这也是 rip + addressing 方式能够工作的基础。
对于 heap 与 stack 这种动态变化的段,其中分配的变量就不能通过这种方式来访问了。stack 中分配的变量一般是通过与栈底指针 + 偏移来访问的。而 heap 中分配的地址,常常要依赖全局变量来完成。
参考文章
Rekicatabke Global Data on x86
Understanding the x64 code models