今天研究了下PIC,记录下。
1)什么是PIC,为什么要PIC
PIC,即Position independent code,直接翻译就是位置无关代码,简单的说就是这个代码可以被load到内存的任意位置而不用做任何地址修正。这里指的是代码段,数据段可能需要地址修正。
PIC是share library机制的基础之一,要实现library在各个process之间可以share,代码必须是PIC。为什么?有了load时的relocation还不够么?答案是也可以够,但是仅仅有load时的relocation,基本等于没有用。share library的主要目的是让各个process可以共享common的代码(指代码段),这部分代码只在内存中占用一次内存,所有process共享这部分代码,而不需要每个process都有一份拷贝。因为代码段是要被share的,所以代码段的内容不能被改变。load时的relocation是在load时对代码做地址修正,所以一旦library被load了,这个library在所有共享该library的process的地址空间中的位置也确定了。有人会问这样有什么问题么?OS在第一次load这个library的时候完全可以找个available的地址空间阿,因为第一次load的时候,是可以被load到内存的任意地方的,所以只要有地址空间就可以。遗憾的是,这种情况下地址空间很可能不够,而导致没法load。考虑这样的问题,如果某人写了个捣蛋的library,占用很大的地址空间,一旦这个library被load了,就会导致其他library不能被load,因为已经没有available的地址空间了,这样这个系统就会崩溃。所以要实现share library,仅仅load时的relocation是不够的,我们需要一种机制,可以让library被load进process的任意地址空间,或者说library在不同的process中,可以被load到不同的地址空间,然后在OS层,通过OS的地址空间映射,来实现library在物理内存上的share。所以PIC是必须的。
2)怎么实现PIC
PIC需要解决的问题就是找到一种办法,避免load时的地址修正(relocation)。以下面的代码为例,该代码把内存中符号one_dword对应的地址的一个dword的内容放到%eax里。如果一个library包含下面的代码,则这个代码不是PIC的。因为$one_byte会随着该代码被load到不同的地址而有不同的值,这就导致了代码段在load到不同地址时,内容(第一句mov指令)会不同,这就导致了无法share。
.text
movl $one_byte, %ebx
movl (%ebx), %eax
.data
.align 4
one_dword:
.byte 1
以ELF格式为例来说明PIC如何避免地址修正。在ELF格式中,各个代码段数据段都有固定的位置,代码段中某条代码的位置到数据段的地址都是固定的。所以如果某条指令要引用一个数据的时候,如果能得到当前指令的地址,就可以通过加上到数据段的固定偏移来找到这个数据。ELF格式中引入了Global Offset Table (GOT)来实现这个机制。GOT就是一系列地址的数组,包含了所有全局数据的地址。
| |
|---------------|
| data_A |
Data section |---------------|
| data_B |
------->|---------------|
| |
| ... |
| |
|---------------|
| addr of data_A|
|---------------|
GOT base| addr of data_B|
------->|---------------|
| |
| |
| |
| ... |
Text section | |
------->|---------------|
根据GOT,经过下面的三步,就可以寻址到特定数据。
1)得到当前指令的地址
2)根据固定的偏移找到GOT的基地址
3)根据数据的符号固定偏移找到该数据的地址
下面的代码实现了这一过程:
call tmp_label /* will push EIP to stack */
tmp_label:
popl %ecx /* %ecx now has address of $tmp_label */
addl $GOT_TABLE_OFFSET_TO_CUR +[. - $tmp_label], %ecx /* %ecx now has the base address of GOT */
上面的代码中,GOT_TABLE_OFFSET_TO_CUR是所在代码的地址到GOT基地址的固定偏移,这个是compiler & linker决定的。执行上面最后一句后,%ecx里已经是GOT的基地址了,然后就可以根据%ecx寻址数据:
movl data_symbol_offset(%ecx), %ebx /* %ebx now has address of target data */
movl (%ebx), %eax /* move data to %eax */
data_symbol_offset也是在编译连接的过程中确定,是固定值。
一个library中包含了一个全局的GOT,每个library都有自己的GOT。在load时,GOT对每个进程都是私有的,这个和数据段一样。如果某个library引用了另一个library的数据,则该library的GOT里也包含这个数据的地址,只不过这个地址是在dynamic linker在load library的时候负责填入的,编译链接阶段无法确定这个值,ELF格式中定义了特定的类型来表示这种数据。
3)一个具体例子
写一个简单的例子来验证PIC的实现。libtest2.so只包含一个数据,被libtest.so引用,main调用libtest.so里的test函数。
kai@opensolaris-kai:~/src/tmp$ cat test.c
static int data = 1;
extern int test2_data;
void test(int p1, int p2, int p3)
{
data += p2;
test2_data += p1;
}
kai@opensolaris-kai:~/src/tmp$ cat test2.c
int test2_data = 3;
kai@opensolaris-kai:~/src/tmp$ cat main.c
void test(int p1, int p2, int p3);
int main(void)
{
int i = 5;
test(2, i, 2);
}
kai@opensolaris-kai:~/src/tmp$ make
gcc -nostdlib -shared -fPIC -s -o libtest2.so test2.c
gcc -nostdlib -shared -fPIC -s -o libtest.so test.c -ltest2
gcc -o main main.c -ltest
objdump -D libtest2.so > test2.S
objdump -D libtest.so > test.S
objdump -D main > main.S
反汇编后的test.S:
Disassembly of section .text:
0000056c :
56c: 55 push %ebp /* %ebp指向caller的stack frame base pointer */
56d: 89 e5 mov %esp,%ebp /* %esp指向test的stack frame base pointer,存进%ebp,用来访问传给test的参数 */
56f: 53 push %ebx
570: e8 00 00 00 00 call 575 /* 地址575会被压入stack */
575: 5b pop %ebx /* %ebx现在等于地址575 */
576: 81 c3 2b 00 01 00 add $0x1002b,%ebx /* %ebx现在等于GOT的base address,0x1002b是compiler & linker计算的 */
57c: 8b 45 0c mov 0xc(%ebp),%eax /* 把p2的值move到%eax,传给test的三个参数分别在%ebp+0x8, %ebp + 0xc, %ebp + 0x10 */
57f: 01 83 10 00 00 00 add %eax,0x10(%ebx) /* %ebp+0x10直接指向了libtest.so里的data,这里没有经过GOT去寻址,猜测应该是经过优化了 */
585: 8b 8b 0c 00 00 00 mov 0xc(%ebx),%ecx /* %ecx, %edx都指向libtest2.so里的test2_data */
58b: 8b 93 0c 00 00 00 mov 0xc(%ebx),%edx
591: 8b 45 08 mov 0x8(%ebp),%eax /* move test2_data到%eax */
594: 03 02 add (%edx),%eax /* add p1 to %eax */
596: 89 01 mov %eax,(%ecx) /* store %eax back to test2_data */
598: 5b pop %ebx
599: 83 c4 00 add $0x0,%esp /* ? */
59c: c9 leave
59d: c3 ret
Disassembly of section .got:
000105a0 <_GLOBAL_OFFSET_TABLE_>:
105a0: 94 xchg %eax,%esp
...
Disassembly of section .data:
000105b0 <_edata-0x4>:
105b0: 01 00 add %eax,(%eax)
...
一个问题:对于library自身数据的访问,似乎不需要GOT?因为可以数据段到代码的偏移也是固定的,完全可以直接得到数据段基地址。
给出执行了push %ebx后的stack的情况(期间有一次地址575的push和pop)。
SFBP = stack frame base pointer
| |
stack top when enter main ---> |-----------------------|
| |
| |
| |
| |
|-----------------------|
| p3 |
|-----------------------|
| p2 |
|-----------------------|
| p1 |
|-----------------------|
| return address in main|
stack top when enter test ---> |-----------------------|
| SFBP of main |
|-----------------------| <--- EBP
| original %ebx |
|-----------------------| <--- ESP
| |
| |
4) Advantage & disadvantage of PIC
PIC的好处显然是在load时可以被load到任意位置而不需要代码段的地址修正,代码可以被不同process share而只留有一份代码在内存中。坏处是增加了额外的对GOT的引用,以及一系列必须的额外的开销(比如对代码段地址的call 和pop等),使得代码运行速度比飞PIC的慢。
另外,由于GOT里的数据地址也是需要在load时计算的,所以对于一些拥有大量数据的library,load的时间也会变慢。
一个问题:对于library自身数据的访问,似乎不需要GOT?因为可以数据段到代码的偏移也是固定的,完全可以直接得到数据段基地址。这似乎可以大大减少load时对GOT里地址的修正所带来的额外时间的花销。
5) Reference
a. Intel IA-32 Architectures Manual Volume1 Basic Architecture, CHAPTER 6, PROCEDURE CALLS, INTERRUPTS, AND EXCEPTIONS
b. Linkers & Loaders, Chapter 8, Loading and overlays, Position indenpendent code
c. http://bottomupcs.sourceforge.net/csbu/x3824.htm