PIC的实现

2019-04-15 12:59发布

今天研究了下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