理解x64代码模型
原作者:Eli Bendersky
http://eli.thegreenplace.net/2012/01/03/understanding-the-x64-code-models
在编写x64架构代码时一个有趣的问题是使用哪个代码模型。这可能是一个不广为人知的议题,但如果想理解编译器生成的x64机器代码。熟悉代码模型是有教育意义的。这与优化及对哪些真正关心性能,从哪怕最小的指令也要抠性能的人有密切关系。
关于这个议题网上或别处的资料非常少。
眼下为止最重要的资源是官方的x64 ABI,你能够从x86-64.org网页获取(以下我将其简称为ABI)。
在gccman-page上也有一点信息。本文的目的是提供一个通俗易懂的參考,给出该议题的一些讨论。及展示真实代码中的概念的详细样例。
一项重要的免责声明:这不是给刚開始学习的人的教程。
要求深刻理解C及汇编语言,加上对x64架构的基本了解。
代码模型——动机
在x64上对代码及数据的訪问通过指令相对取址模型(x64的说法是RIP-相对)完毕。在这些指令里RIP的相对偏移限制在32比特。那么在32比特不够用时我们该怎么做?如果程序超过2GB该怎么办?当指令尝试訪问某些代码(或数据)。但不能通过RIP的32比特偏移做到时。就会出现故障。
这个问题的一个解决的方法是放弃RIP相对取址模式,对全部的代码及数据的引用使用64位绝对偏移。但这个办法代价非常高——运行最简单的操作须要很多其它的指令。
这是为了(极少见的)极其庞大的程序或库,而在全部代码中支付高昂的代价。
因此,折中的办法是代码模式[1]。代码模式是程序猿与编译器间的一个正式的协议。当中程序猿陈述他对终于程序将进入的当前正在编译的目标文件大小的意愿[2]。
代码模型就是为了让程序猿告诉编译器:不要操心,这个对象仅仅会进入不那么大的程序,因此你能够使用高速的RIP相对取址模式。相反,他能够告诉编译器:这个对象期望链接进巨大的程序,因此请使用慢但安全的,带有完整64位偏移的绝对取址模式。
这里会谈什么
上面谈论的两个场景有名字:向编译器承诺。在编译出的对象中32位相对偏移对全部的代码及对象訪问都够用的小代码模型(thesmall code model)。
还有一方面。大代码模型(The large code model)告诉编译器不要进行不论什么如果,使用64位绝对取址模型訪问代码及数据。
更有趣的是。还有中间的道路,称为中等代码模型(the mediumcode model)。
非PIC及PIC代码都分别存在这些代码模型。
本文准备讨论全部6种变形。
C样例源代码
我将使用以下的C程序,以不同的代码模型,来展示本文中讨论的概念。在这个代码里,main函数訪问4个不同的全局数组及一个全局函数。数组有两个不同的參数:大小及可见性。
大小对解释中等代码模型是重要的,对小的及大的模型将不起作用。
可见性是static(仅在源文件里可见)或全局(对链接到该程序的全部其它对象可见)。
这个差别对PIC代码模型是重要的。
int global_arr[100] = {2, 3};
staticint static_arr[100] = {9, 7};
int global_arr_big[50000] = {5, 6};
staticint static_arr_big[50000] = {10, 20};
intglobal_func(int param)
{
return param * 10;
}
intmain(int argc, constchar* argv[])
{
int t =global_func(argc);
t += global_arr[7];
t += static_arr[7];
t += global_arr_big[7];
t += static_arr_big[7];
return t;
}
Gcc接受-mcmodel选项值作为代码模型,能够-fpic标记来指定PIC编译。
比如,以大代码模型及启用PIC编译:
> gcc -g -O0 -c codemodel1.c -fpic -mcmodel=large -ocodemodel1_large_pic.o
小代码模型
man gcc这样谈到小代码模型:
-mcmodel=small
为小代码模型生成代码:必须在地址空间的低2GB链接程序及其符号。指针是64位的。
能够静态或动态链接程序。这是缺省的代码模型。
换而言之,编译器假定全部的代码及数据能够从代码的不论什么指令处以32位RIP相对偏移訪问。让我们看一下样例C程序以非PIC小代码模型编译的汇编:
> objdump -dS codemodel1_small.o
[...]
int main(int argc, const char* argv[])
{
15: 55 push %rbp
16: 48 89 e5 mov %rsp,%rbp
19: 48 83 ec 20 sub $0x20,%rsp
1d: 89 7d ec mov %edi,-0x14(%rbp)
20: 48 89 75 e0 mov %rsi,-0x20(%rbp)
int t =global_func(argc);
24: 8b 45 ec mov -0x14(%rbp),%eax
27: 89 c7 mov %eax,%edi
29: b8 00 00 00 00 mov $0x0,%eax
2e: e8 00 00 00 00 callq 33 <main+0x1e>
33: 89 45 fc mov %eax,-0x4(%rbp)
t += global_arr[7];
36: 8b 05 00 00 0000 mov 0x0(%rip),%eax
3c: 01 45 fc add %eax,-0x4(%rbp)
t += static_arr[7];
3f: 8b 05 00 00 0000 mov 0x0(%rip),%eax
45: 01 45 fc add %eax,-0x4(%rbp)
t +=global_arr_big[7];
48: 8b 05 00 00 0000 mov 0x0(%rip),%eax
4e: 01 45 fc add %eax,-0x4(%rbp)
t +=static_arr_big[7];
51: 8b 05 00 00 0000 mov 0x0(%rip),%eax
57: 01 45 fc add %eax,-0x4(%rbp)
return t;
5a: 8b 45 fc mov -0x4(%rbp),%eax
}
5d: c9 leaveq
5e: c3 retq
能够看到。全部的数组以相同的方式訪问——通过使用简单的RIP相对偏移。只是,代码中的偏移是0,由于编译器不知道代码段将放在哪里。因此为每一个这种訪问它还创建了重定位信息。
> readelf -r codemodel1_small.o
Relocation section '.rela.text' at offset 0x62bd8 contains 5entries:
Offset Info Type Sym. Value Sym. Name + Addend
00000000002f 001500000002R_X86_64_PC32 0000000000000000global_func - 4
000000000038 001100000002R_X86_64_PC32 0000000000000000global_arr + 18
000000000041 000300000002R_X86_64_PC32 0000000000000000 .data+ 1b8
00000000004a 001200000002R_X86_64_PC32 0000000000000340 global_arr_big+ 18
000000000053 000300000002R_X86_64_PC32 0000000000000000 .data+ 31098
让我们完整地解码对global_arr的訪问作为一个样例。这里是相关的汇编部分:
t += global_arr[7];
36: 8b 05 00 00 0000 mov 0x0(%rip),%eax
3c: 01 45 fc add %eax,-0x4(%rbp)
RIP相对取址是相对于下一条指令的。因此填充到mov指令的偏移应该相对于0x3c。相关的重定位是第二个。
指向0x38处mov的操作数。它是R_X86_64_PC32,意即:获取符号值,加上Addend。减去这个重定位指向的偏移。如果计算。你会发现这终于会置入下一条指令与global_arr之间的相对偏移,加上0x1c。这个相对偏移正是我们须要的。由于0x1c就是表示“数组的第7个int”(x64的每一个int是4字节)。因此使用RIP相对取址。指令正确地訪问global_arr[7]。
这里要注意的还有一件有趣的事是,虽然訪问static_arr的指令是相似的,它的重定位有不同的符号。指向.data段而不是特定的符号。这是由于静态数组由链接器放在.data段的一个已知位置——它不能被其它共享库所共享。这个重定位将终于由链接器全然解析。还有一方面,对global_arr的引用将交由动态加载器解析。由于global_arr能够被不同的共享库使用(或覆盖)[3]。
最后,让我们看一下对global_func的引用:
int t =global_func(argc);
24: 8b 45 ec mov -0x14(%rbp),%eax
27: 89 c7 mov %eax,%edi
29: b8 00 00 0000 mov $0x0,%eax
2e: e8 00 00 0000 callq 33 <main+0x1e>
33: 89 45 fc mov %eax,-0x4(%rbp)
Callq的操作数也是RIP相对的,一次这里的R_X86_64_PC32重定位的工作相似于向操作数放置到global_func的实际相对偏移。
总结,由于小代码模型向编译器承诺在终于程序中全部代码及数据能够使用32位RIP相对偏移訪问,编译器能够为訪问这些类型的对象生成简单且高效的代码。
大代码模型
摘自man gcc:
-mcmodel=large
为大模型生成代码:这个模型不正确地址及段的大小进行如果。
以下是以非PIC大代码模型编译的main的汇编代码:
int main(int argc, const char* argv[])
{
15: 55 push %rbp
16: 48 89 e5 mov %rsp,%rbp
19: 48 83 ec 20 sub $0x20,%rsp
1d: 89 7d ec mov %edi,-0x14(%rbp)
20: 48 89 75 e0 mov %rsi,-0x20(%rbp)
int t =global_func(argc);
24: 8b 45 ec mov -0x14(%rbp),%eax
27: 89 c7 mov %eax,%edi
29: b8 00 00 00 00 mov $0x0,%eax
2e: 48 ba 00 00 00 0000 movabs $0x0,%rdx
35: 00 00 00
38: ff d2 callq *%rdx
3a: 89 45 fc mov %eax,-0x4(%rbp)
t += global_arr[7];
3d: 48 b8 00 00 00 0000 movabs $0x0,%rax
44: 00 00 00
47: 8b 40 1c mov 0x1c(%rax),%eax
4a: 01 45 fc add %eax,-0x4(%rbp)
t += static_arr[7];
4d: 48 b8 00 00 00 0000 movabs $0x0,%rax
54: 00 00 00
57: 8b 40 1c mov 0x1c(%rax),%eax
5a: 01 45 fc add %eax,-0x4(%rbp)
t += global_arr_big[7];
5d: 48 b8 00 00 00 0000 movabs $0x0,%rax
64: 00 00 00
67: 8b 40 1c mov 0x1c(%rax),%eax
6a: 01 45 fc add %eax,-0x4(%rbp)
t +=static_arr_big[7];
6d: 48 b8 00 00 00 0000 movabs $0x0,%rax
74: 00 00 00
77: 8b 40 1c mov 0x1c(%rax),%eax
7a: 01 45 fc add %eax,-0x4(%rbp)
return t;
7d: 8b 45 fc mov -0x4(%rbp),%eax
}
80: c9 leaveq
81: c3 retq
再一次,看到重定位将是实用的:
Relocation section '.rela.text' at offset 0x62c18 contains 5entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000000030 001500000001R_X86_64_64 0000000000000000global_func + 0
00000000003f 001100000001R_X86_64_64 0000000000000000global_arr + 0
00000000004f 000300000001R_X86_64_64 0000000000000000 .data+ 1a0
00000000005f 001200000001R_X86_64_64 0000000000000340global_arr_big + 0
00000000006f 000300000001R_X86_64_64 0000000000000000 .data+ 31080
大代码模型也是相当统一——没有代码段及数据段的大小的假定,因此全部的数据都以相似的方式訪问。让我们再次选global_arr:
t += global_arr[7];
3d: 48 b8 00 00 0000 00 movabs $0x0,%rax
44: 00 00 00
47: 8b 40 1c mov 0x1c(%rax),%eax
4a: 01 45 fc add %eax,-0x4(%rbp)
这里从数组获取期望的值须要两条指令。
第一条把一个64位绝对地址放入rax。
我们非常快会看到。这是global_arr的地址。第二条将 (rax) + 0x1c处的字加载eax。
这样,让我们关注0x3d处的指令。
它是movabs——x64上绝对64位版本号的mov。
它能够将一个完整的64位马上数转入一个寄存器。汇编代码中这个马上数的值是0,因此我们必须求助于重定位表。0x3f处的操作数有一个R_X86_64_64重定位。
这是一个绝对重定位,它仅仅是表示——将符号值+addend放进偏移。换而言之。rax将持有global_arr的绝对地址。
函数调用又怎样呢?
int t =global_func(argc);
24: 8b 45 ec mov -0x14(%rbp),%eax
27: 89 c7 mov %eax,%edi
29: b8 00 00 0000 mov $0x0,%eax
2e: 48 ba 00 00 0000 00 movabs $0x0,%rdx
35: 00 00 00
38: ff d2 callq *%rdx
3a: 89 45 fc mov %eax,-0x4(%rbp)
在一个熟悉的movabs之后,我们有一条调用一个地址在rdx的函数的call指令。
瞄一眼相关的重定位,显然这非常相似于数据訪问。
非常明显,大代码模型全然没有对数据与代码段大小。或者符号在哪里做出不论什么如果。它仅仅是到处都採取“安全道路”。使用绝对64位move来引用符号。
当然。这是有代价的。注意到与小代码模型相比。如今訪问不论什么符号都须要一条额外的指令。
因此,我们注意到了两个极端。小代码模型非常高兴地假定不论什么东西都放进低2GB内存,而大模型假定不论什么东西及符号都可能出如今整个64位地址空间的不论什么地方。
中间代码模型则是一个折衷。
中间代码模型
像前面那样,让我们以查询man gcc開始:
-mcmodel=medium
为中间模型生成代码:程序被链接进地址空间的低2GB。小符号也放在那里。大小超过-mlarge-data-threshold的符号放在大数据段或bss段,而且能够位于2GB以上。
程序能够被静态或动态链接。
相似于小代码模型,中间代码模型假定全部的代码被链接进低2GB。还有一方面,数据被分为“大数据”及“小数据”。小数据被假定链接进低2GB。相反,不限制大数据在内存的位置。在大于给定的门限选项时,数据被觉得是大的,这个值缺省是64KB。
有趣的是注意到在中间代码模型里,为大数据构建了特殊的段——.ldata与.lbss(相应.data与.bss)。只是对本文而言它并不重要。因此我准备绕过这个议题。很多其它细节參考ABI。
如今应该清楚为什么样例C代码有那些_big数组。
这旨在让中间代码模式视它们为“大数据”(每一个200KB,它们当然是)。以下是汇编:
int main(int argc, const char* argv[])
{
15: 55 push %rbp
16: 48 89 e5 mov %rsp,%rbp
19: 48 83 ec 20 sub $0x20,%rsp
1d: 89 7d ec mov %edi,-0x14(%rbp)
20: 48 89 75 e0 mov %rsi,-0x20(%rbp)
int t =global_func(argc);
24: 8b 45 ec mov -0x14(%rbp),%eax
27: 89 c7 mov %eax,%edi
29: b8 00 00 00 00 mov $0x0,%eax
2e: e8 00 00 00 00 callq 33 <main+0x1e>
33: 89 45 fc mov %eax,-0x4(%rbp)
t += global_arr[7];
36: 8b 05 00 00 0000 mov 0x0(%rip),%eax
3c: 01 45 fc add %eax,-0x4(%rbp)
t += static_arr[7];
3f: 8b 05 00 00 0000 mov 0x0(%rip),%eax
45: 01 45 fc add %eax,-0x4(%rbp)
t += global_arr_big[7];
48: 48 b8 00 00 00 0000 movabs $0x0,%rax
4f: 00 00 00
52: 8b 40 1c mov 0x1c(%rax),%eax
55: 01 45 fc add %eax,-0x4(%rbp)
t +=static_arr_big[7];
58: 48 b8 00 00 00 0000 movabs $0x0,%rax
5f: 00 00 00
62: 8b 40 1c mov 0x1c(%rax),%eax
65: 01 45 fc add %eax,-0x4(%rbp)
return t;
68: 8b 45 fc mov -0x4(%rbp),%eax
}
6b: c9 leaveq
6c: c3 retq
注意到_big数组以大模型訪问,当中数组以小模型訪问。
函数也以小模型訪问。
我没有展示重定位,由于没有新的东西。
中间代码模型是小代码模型与大代码模型间的一个聪明的折衷。程序代码不太可能超级大[4],因此让它超过2GB门限的是静态链接的大块的数据(可能是某些大的查找表。
)。中间代码模型将这些大块数据与其它部分分开,特别地处理它们。
全部调用函数及訪问其它较小符号的代码将像小代码模型那么高效。仅实际訪问大符号的代码将使用相似于大代码模型的完整64位方式。
小PIC代码模型
如今让我们转向PIC的代码模型。也是以小模型開始[5]。
以下是以PIC及小代码模型编译的样例代码:
int main(int argc, const char* argv[])
{
15: 55 push %rbp
16: 48 89 e5 mov %rsp,%rbp
19: 48 83 ec 20 sub $0x20,%rsp
1d: 89 7d ec mov %edi,-0x14(%rbp)
20: 48 89 75 e0 mov %rsi,-0x20(%rbp)
int t =global_func(argc);
24: 8b 45 ec mov -0x14(%rbp),%eax
27: 89 c7 mov %eax,%edi
29: b8 00 00 00 00 mov $0x0,%eax
2e: e8 00 00 00 00 callq 33 <main+0x1e>
33: 89 45 fc mov %eax,-0x4(%rbp)
t += global_arr[7];
36: 48 8b 05 00 00 00 00 mov 0x0(%rip),%rax
3d: 8b 40 1c mov 0x1c(%rax),%eax
40: 01 45 fc add %eax,-0x4(%rbp)
t += static_arr[7];
43: 8b 05 00 00 00 00 mov 0x0(%rip),%eax
49: 01 45 fc add %eax,-0x4(%rbp)
t +=global_arr_big[7];
4c: 48 8b 05 00 00 00 00 mov 0x0(%rip),%rax
53: 8b 40 1c mov 0x1c(%rax),%eax
56: 01 45 fc add %eax,-0x4(%rbp)
t +=static_arr_big[7];
59: 8b 05 00 00 00 00 mov 0x0(%rip),%eax
5f: 01 45 fc add %eax,-0x4(%rbp)
return t;
62: 8b 45 fc mov -0x4(%rbp),%eax
}
65: c9 leaveq
66: c3 retq
以及重定位信息:
Relocation section '.rela.text' at offset 0x62ce8 contains 5entries:
Offset Info Type Sym. Value Sym. Name + Addend
00000000002f 001600000004R_X86_64_PLT32 0000000000000000global_func - 4
000000000039 001100000009R_X86_64_GOTPCREL 0000000000000000 global_arr - 4
000000000045 000300000002R_X86_64_PC32 0000000000000000 .data+ 1b8
00000000004f 001200000009R_X86_64_GOTPCREL 0000000000000340 global_arr_big - 4
00000000005b 000300000002R_X86_64_PC32 0000000000000000 .data+ 31098
由于数据大小的差别在小模型里不起作用,我们将关注局部(静态)与全局符号间的差别,在生成PIC时,这个差别发挥了作用。
正如你能看到的,为静态数组生成的代码与非PIC情形里生成的代码全然等价。
这是x64架构的恩赐之中的一个——除非从外部訪问符号。归因于数据的RIP相对取址。无需等价你就能得到PIC。使用的指令与重定位也是一样的,因此我们不再过一遍了。
这里有趣的地方是全局数组。
回顾在PIC里,全局数据必须通过GOT,由于它可能终于在其它共享库中找到或被使用[6]。
以下是訪问global_arr的生成代码:
t += global_arr[7];
36: 48 8b 05 00 00 0000 mov 0x0(%rip),%rax
3d: 8b 40 1c mov 0x1c(%rax),%eax
40: 01 45 fc add %eax,-0x4(%rbp)
相关的重定位是一个R_X86_64_GOTPCREL,它表示:该符号的入口位置在GOT+ addend。减去所採用重定位的Offset。换句话说,RIP(下一条指令)与GOT中global_arr的项之间的相对偏移被填充到指令。因此在0x36处指令里放入rax的是global_arr的实际地址。后跟将global_arr加上到第七个元素偏移得到的地址解引用到eax。
如今让我们查看函数调用:
int t =global_func(argc);
24: 8b 45 ec mov -0x14(%rbp),%eax
27: 89 c7 mov %eax,%edi
29: b8 00 00 00 00 mov $0x0,%eax
2e: e8 00 00 00 00 callq 33 <main+0x1e>
33: 89 45 fc mov %eax,-0x4(%rbp)
在0x2e处callq的操作数有一个R_X86_64_PLT32重定位。
这个重定位表示:该符号的PLT项地址 + addend,减去所採用重定位的Offset 。
换句话说,该callq将正确调用global_func的PLTtrampoline。
注意编译器所做的隐含如果——能够RIP相对取址方式訪问GOT与PLT。在与其它PIC代码模型比較时。这是重要的差别。
大PIC代码模型
以下是汇编代码:
int main(int argc, const char* argv[])
{
15: 55 push %rbp
16: 48 89 e5 mov %rsp,%rbp
19: 53 push %rbx
1a: 48 83 ec 28 sub $0x28,%rsp
1e: 48 8d 1d f9 ff ffff lea -0x7(%rip),%rbx
25: 49 bb 00 00 00 0000 movabs $0x0,%r11
2c: 00 00 00
2f: 4c 01 db add %r11,%rbx
32: 89 7d dc mov %edi,-0x24(%rbp)
35: 48 89 75 d0 mov %rsi,-0x30(%rbp)
int t =global_func(argc);
39: 8b 45 dc mov -0x24(%rbp),%eax
3c: 89 c7 mov %eax,%edi
3e: b8 00 00 00 00 mov $0x0,%eax
43: 48 ba 00 00 00 0000 movabs $0x0,%rdx
4a: 00 00 00
4d: 48 01 da add %rbx,%rdx
50: ff d2 callq *%rdx
52: 89 45 ec mov %eax,-0x14(%rbp)
t += global_arr[7];
55: 48 b8 00 00 00 0000 movabs $0x0,%rax
5c: 00 00 00
5f: 48 8b 04 03 mov (%rbx,%rax,1),%rax
63: 8b 40 1c mov 0x1c(%rax),%eax
66: 01 45 ec add %eax,-0x14(%rbp)
t += static_arr[7];
69: 48 b8 00 00 00 0000 movabs $0x0,%rax
70: 00 00 00
73: 8b 44 03 1c mov 0x1c(%rbx,%rax,1),%eax
77: 01 45 ec add %eax,-0x14(%rbp)
t +=global_arr_big[7];
7a: 48 b8 00 00 00 0000 movabs $0x0,%rax
81: 00 00 00
84: 48 8b 04 03 mov (%rbx,%rax,1),%rax
88: 8b 40 1c mov 0x1c(%rax),%eax
8b: 01 45 ec add %eax,-0x14(%rbp)
t +=static_arr_big[7];
8e: 48 b8 00 00 00 0000 movabs $0x0,%rax
95: 00 00 00
98: 8b 44 03 1c mov 0x1c(%rbx,%rax,1),%eax
9c: 01 45 ec add %eax,-0x14(%rbp)
return t;
9f: 8b 45 ec mov -0x14(%rbp),%eax
}
a2: 48 83 c4 28 add $0x28,%rsp
a6: 5b pop %rbx
a7: c9 leaveq
a8: c3 retq
以及重定位信息:
Relocation section '.rela.text' at offset 0x62c70 contains 6entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000000027 00150000001dR_X86_64_GOTPC64 0000000000000000_GLOBAL_OFFSET_TABLE_ + 9
000000000045 00160000001fR_X86_64_PLTOFF64 0000000000000000 global_func + 0
000000000057 00110000001bR_X86_64_GOT64 0000000000000000global_arr + 0
00000000006b 000800000019R_X86_64_GOTOFF64 00000000000001a0 static_arr + 0
00000000007c 00120000001bR_X86_64_GOT64 0000000000000340global_arr_big + 0
000000000090 000900000019R_X86_64_GOTOFF64 0000000000031080 static_arr_big + 0
重新,数据大小的差别不重要,因此我们关注在static_arr及global_arr。
但首先,在这个代码里有一个我们之前没遇到过的新的prologue:
1e: 48 8d 1d f9 ff ff ff lea -0x7(%rip),%rbx
25: 49 bb 00 00 00 00 00 movabs $0x0,%r11
2c: 00 00 00
2f: 4c 01 db add %r11,%rbx
以下摘自ABI:
在小代码模型里。全部的地址(包括GOT项)可通过由AMD64架构提供的IP相对取址来訪问。因此不须要一个显式的GOT指针。进而不须要设置它的函数prologue。
在中间及大代码模型里,必须分配一个寄存器来保存位置无关对象里GOT的地址,由于AMD64 ISA不支持大于32位的马上数。
让我们看一下上面显示的prologue怎样计算GOT的地址。首先,0x1e处的指令将自己的地址加载rbx。
然后,一个绝对64位move运行到r11。伴随一个R_X86_64GOTPC64重定位。
这个重定位表示:获取GOT地址,减去被重定位的偏移,加上addend。最后,0x2f处的指令将两者相加。
结果在rbx中是GOT的绝对地址[7]。
为什么要这么麻烦去计算GOT的地址呢?嗯,一方面。正如文摘中说的。在大模型里我们不能假定32位RIP相对偏移足以訪问GOT。因此我们须要一个完整64位地址。还有一方面,我们仍然希望PIC,因此我们不能仅仅是将绝对地址放进寄存器。
相反,这个地址必须相对于RIP计算。
这正是这里prologue做的事。它仅仅是一个64位RIP相关计算。
不管怎样,如今在rbx里我们牢牢地掌握了GOT的地址,让我们看一下怎样訪问static_arr:
t += static_arr[7];
69: 48 b8 00 00 0000 00 movabs $0x0,%rax
70: 00 00 00
73: 8b 44 03 1c mov 0x1c(%rbx,%rax,1),%eax
77: 01 45 ec add %eax,-0x14(%rbp)
第一条指令的重定位是R_X86_64_GOTOFF64,它表示:symbol+ addend – GOT。在我们情形里:static_arr地址与GOT地址间的相对偏移。下一条指令把它与rbx(GOT绝对地址)相加,加上偏移0x1c并解引用。
为了这个计算更直观,以下是一些C伪代码:
// char* static_arr
// char* GOT
rax = static_arr + 0 - GOT; // rax now contains an offset
eax = *(rbx + rax + 0x1c); // rbx == GOT, so eax now contains
// *(GOT + static_arr - GOT + 0x1c) or
// *(static_arr + 0x1c)
这里注意一件有趣的事:GOT地址仅仅用作抵达static_arr的一个锚点。这不像GOT通常的实际包括一个符号地址的使用方法。由于static_arr不是一个外部符号,没有理由把它保存在GOT里。
相同,这里GOT被用作数据段中的一个锚点,相对于能以完整的64位偏移找出的符号地址,这同一时候是位置无关的(链接器将能够解析这个重定位,不须要在加载时改动代码段)。
global_arr又怎样呢?
t += global_arr[7];
55: 48 b8 00 00 0000 00 movabs $0x0,%rax
5c: 00 00 00
5f: 48 8b 04 03 mov (%rbx,%rax,1),%rax
63: 8b 40 1c mov 0x1c(%rax),%eax
66: 01 45 ec add %eax,-0x14(%rbp)
代码略微长一点,重定位也不一样。这实际上是GOT更为传统的使用方法。movabs的重定位R_X86_64_GOT64表示它将偏移保存到GOT,那样global_arr的地址将位于rax。0x5f处的指令从GOT获取global_arr的地址并放进rax。下一条指令解引用global_arr[7],将值置于eax。
如今让我们看一下对訪问global_func的代码。
回顾在大代码模型里我们不能对代码段的大小做不论什么如果,因此我们应该假定即使訪问PLT也须要一个绝对的64位地址:
int t =global_func(argc);
39: 8b 45 dc mov -0x24(%rbp),%eax
3c: 89 c7 mov %eax,%edi
3e: b8 00 00 00 00 mov $0x0,%eax
43: 48 ba 00 00 00 00 00 movabs $0x0,%rdx
4a: 00 00 00
4d: 48 01 da add %rbx,%rdx
50: ff d2 callq *%rdx
52: 89 45 ec mov %eax,-0x14(%rbp)
相关的重定位是一个R_X86_64_PLTOFF64。它表示:global_func的PLT项地址。减去GOT地址。这被放入rdx。随后加上rbx(GOT的绝对地址)。
rdx中的结果就是global_func的PLT项地址。
相同。注意GOT用作一个“锚点”,以使位置无关地訪问PLT项偏移成为可能。
中间PIC代码模型
最后。我们将查看为中间PIC代码模型生成的代码:
int main(int argc, const char* argv[])
{
15: 55 push %rbp
16: 48 89 e5 mov %rsp,%rbp
19: 53 push %rbx
1a: 48 83 ec 28 sub $0x28,%rsp
1e: 48 8d 1d 00 00 00 00 lea 0x0(%rip),%rbx
25: 89 7d dc mov %edi,-0x24(%rbp)
28: 48 89 75 d0 mov %rsi,-0x30(%rbp)
int t =global_func(argc);
2c: 8b 45 dc mov -0x24(%rbp),%eax
2f: 89 c7 mov %eax,%edi
31: b8 00 00 00 00 mov $0x0,%eax
36: e8 00 00 00 00 callq 3b <main+0x26>
3b: 89 45 ec mov %eax,-0x14(%rbp)
t += global_arr[7];
3e: 48 8b 05 00 00 00 00 mov 0x0(%rip),%rax
45: 8b 40 1c mov 0x1c(%rax),%eax
48: 01 45 ec add %eax,-0x14(%rbp)
t += static_arr[7];
4b: 8b 05 00 00 00 00 mov 0x0(%rip),%eax
51: 01 45 ec add %eax,-0x14(%rbp)
t +=global_arr_big[7];
54: 48 8b 05 00 00 00 00 mov 0x0(%rip),%rax
5b: 8b 40 1c mov 0x1c(%rax),%eax
5e: 01 45 ec add %eax,-0x14(%rbp)
t +=static_arr_big[7];
61: 48 b8 00 00 00 00 00 movabs $0x0,%rax
68: 00 00 00
6b: 8b 44 03 1c mov 0x1c(%rbx,%rax,1),%eax
6f: 01 45 ec add %eax,-0x14(%rbp)
return t;
72: 8b 45 ec mov -0x14(%rbp),%eax
}
75: 48 83 c4 28 add $0x28,%rsp
79: 5b pop %rbx
7a: c9 leaveq
7b: c3 retq
以及重定位信息:
Relocation section '.rela.text' at offset 0x62d60 contains 6entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000000021 00160000001aR_X86_64_GOTPC32 0000000000000000_GLOBAL_OFFSET_TABLE_ - 4
000000000037 001700000004R_X86_64_PLT32 0000000000000000global_func - 4
000000000041 001200000009R_X86_64_GOTPCREL 0000000000000000 global_arr - 4
00000000004d 000300000002R_X86_64_PC32 0000000000000000 .data+ 1b8
000000000057 001300000009R_X86_64_GOTPCREL 0000000000000000 global_arr_big - 4
000000000063 000a00000019R_X86_64_GOTOFF64 0000000000030d40 static_arr_big + 0
首先,让我们清除不相关的函数调用。相似于小模型。在中间模型里我们如果代码的訪问在RIP的32位偏移范围内。因此,调用global_func的代码全然相似于小PIC模型。小数据数组arraysstatic_arr与global_arr也一样。因此我们将关注在大数据数组上,但首先让我们讨论prologue,它与大模型是不同的:
1e: 48 8d 1d 00 00 0000 lea 0x0(%rip),%rbx
就是说,单条指令(而不是大模型里的3条)将GOT的地址获取到rbx(在重定位R_X86_64_GOTPC32的辅助下)。为什么不一样?由于在中间代码模型里。我们如果GOT本身能够32位偏移訪问,由于它不是“大数据段”的部分。在大代码模型里。我们不能做这个如果,必须使用完整的64位偏移来訪问GOT。
有趣的是,我们注意到訪问global_arr_big的代码也相似于小PIC模型。为什么?出于相同的原因,prologue比大模型中的要短。在中间模型里,我们如果GOT本身能够32位RIP相对取址訪问。global_arr_big本身却不是。但这能够被GOT覆盖,由于global_arr_big的地址实际位于GOT里,在那里它是一个完整64位地址。
至于static_arr_big,情形是不同的:
t += static_arr_big[7];
61: 48 b8 00 00 00 0000 movabs $0x0,%rax
68: 00 00 00
6b: 8b 44 03 1c mov 0x1c(%rbx,%rax,1),%eax
6f: 01 45 ec add %eax,-0x14(%rbp)
这实际上相似于大PIC代码模型,由于这里我们确实获得了符号的一个绝对地址,它本身不是位于GOT里。由于这是一个大符号,不能如果它位于低2GB,这里我们须要64位PIC偏移,相似于大模型。
[2]要记住一个重要的事情:实际指令由编译器产生,取址模式在那个阶段“成型”。编译器没有办法知道它正在编译的对象将进入哪些程序或共享库。有些可能是小的,但有些可能是大的。链接器知道结果程序的大小,但已太迟,由于链接器不能更改指令,仅仅能依据重定位在指令中填补偏移。
因此,代码模型“合同”仅仅能由程序猿在编译阶段“签署”。
[4] 虽然这成立。上次我检查,Clang的Debug+Asserts build差点儿有半GB大(归咎于相当大的自己主动生成代码)。
[6] 因此链接器不能单凭自己全然解析这些引用。将GOT的处理留给动态加载器。
[7] 0x25 - 0x7 + GOT - 0x27 + 0x9 = GOT