Blog

共享模块的全局变量

Content #

当一个模块引用了一个定义在共享对象的全局变量。一个共享对象定义了一个全局变量global,而模块module.c中是这么引用的:

extern int global;
int foo()
{
   global = 1;
}

当编译器编译module.c时,它无法根据这个上下文判断global是定义在同一个模块的的其他目标文件还是定义在另外一个共享对象之中,即无法判断是否为跨模块间的调用。

假设module.c是程序可执行文件的一部分,那么由于程序主模块的代码并不是地址无关代码,编译器会产生这样的代码:

movl   $0x1,XXXXXXXX

XXXXXXXX就是global的地址。由于可执行文件在运行时并不进行代码重定位,所以变量的地址必须在链接过程中确定下来。为了能够使得链接过程正常进行,链接器会在创建可执行文件时,在它的“.bss”段创建一个global变量的副本。那么问题就很明显了,现在global变量定义在原先的共享对象中,而在可执行文件的“.bss”段还有一个副本。如果同一个变量同时存在于多个位置中,这在程序实际运行过程中肯定是不可行的。

于是解决的办法只有一个,那就是所有的使用这个变量的指令都指向位于可执行文件中的那个副本。

ELF共享库在编译时,默认都把定义在模块内部的全局变量当作定义在其他模块的全局变量,也就是说当作类型四,通过GOT来实现变量的访问。当共享模块被装载时,如果某个全局变量在可执行文件中拥有副本,那么动态链接器就会把 GOT中的相应地址指向该副本,这样该变量在运行时实际上最终就只有一个实例。如果变量在共享模块中被初始化,那么动态链接器还需要将该初始化值复制到程序主模块中的变量副本;如果该全局变量在程序主模块中没有副本,那么GOT中的相应地址就指向模块内部的该变量副本。

假设module.c是一个共享对象的一部分,那么GCC编译器在-fPIC的情况下,就会把对global的调用按照跨模块模式产生代码。原因也很简单:编译器无法确定对 global的引用是跨模块的还是模块内部的。即使是模块内部的,即模块内部的全局变量的引用,按照上面的结论,还是会产生跨模块代码,因为global可能被可执行文件引用,从而使得共享模块中对global的引用要执行可执行文件中的 global副本。

From #

程序员的自我修养

-fpic和-fPIC

Content #

位置无关代码(Position-Independent Code, PIC),共享库的编译必须使用该选项。

这两个参数从功能上来讲完全一样,都是指示GCC产生地址无关代码。唯一的区别是,“-fPIC”产生的代码要大,而“-fpic”产生的代码相对较小,而且较快。

那么我们为什么不使用“-fpic”而要使用“-fPIC”呢?原因是,由于地址无关代码都是跟硬件平台相关的,不同的平台有着不同的实现,“-fpic”在某些平台上会有一些限制,比如全局符号的数量或者代码的长度等,而“-fPIC”则没有这样的限制。所以为了方便起见,绝大部分情况下我们都使用“-fPIC”参数来产生地址无关代码。

From #

程序员的自我修养

模块内部的数据访问(PIC)

Content #

生成地址无关代码时,碰到模块内部的数据访问。很明显,指令中不能直接包含数据的绝对地址,只能使用相对寻址。

现代的体系结构中,数据的相对寻址往往没有相对与当前指令地址(PC)的寻址方式,所以ELF用了一个很巧妙的办法来得到当前的PC值,然后再加上一个偏移量就可以达到访问相应变量的目的了。

0000044c <bar>:
 44c: 55                    push   %ebp
 44d: 89 e5                 mov    %esp,%ebp
 44f: e8 40 00 00 00        call   494 <__i686.get_pc_thunk.cx>
 454: 81 c1 8c 11 00 00     add    $0x118c,%ecx
 45a: c7 81 28 00 00 00 01  movl   $0x1,0x28(%ecx)         // a = 1
 461: 00 00 00
 464: 8b 81 f8 ff ff ff     mov 0xfffffff8(%ecx),%eax
 46a: c7 00 02 00 00 00     movl   $0x2,(%eax)             // b = 2
 470: 5d                    pop    %ebp
 471: c3                    ret

00000494 <__i686.get_pc_thunk.cx>:
 494: 8b 0c 24              mov    (%esp),%ecx
 497: c3                    ret

从call指令中可以看到,它先调用了一个叫“__i686.get_pc_thunk.cx”的函数,这个函数的作用就是把返回地址的值放到ecx寄存器,即把call的下一条指令的地址放到ecx寄存器。

当处理器执行call指令以后,下一条指令的地址会被压到栈顶,而esp寄存器就是始终指向栈顶的,那么当“__i686.get_pc_thunk.cx”执行“mov (%esp),%ecx”的时候,返回地址就被赋值到ecx寄存器了。

接着执行一条add指令和一条mov指令,可以看到变量a的地址是add指令地址(保存在ecx寄存器)加上两个偏移量0x118c和0x28,即如果模块被装载到 0x10000000这个地址的话,那么变量a的实际地址将是0x10000000 + 0x454 + 0x118c + 0x28 = 0x10001608。

From #

程序员的自我修养

产生地址无关代码的四种情形

Content #

把共享对象模块中的地址引用按照是否为跨模块分成两类:模块内部引用和模块外部引用;按照不同的引用方式又可以分为指令引用和数据访问,这样我们就得到了如图所示的4种情况。第一种是模块内部的函数调用、跳转等。第二种是 模块内部的数据访问(PIC),比如模块中定义的全局变量、静态变量。第三种是模块外部的函数调用、跳转等。第四种是模块外部的数据访问,比如其他模块中定义的全局变量。

From #

程序员的自我修养

地址无关代码(PIC, Position-independent Code)

Content #

我们的目的很简单,希望程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变,所以实现的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种方案就是目前被称为地址无关代码(PIC, Position-independent Code)的技术。

From #

程序员的自我修养

装载时重定位(Load Time Relocation)

装载时重定位(Load Time Relocation)

Content #

早在没有虚拟存储概念的情况下,程序是直接被装载进物理内存的。当同时有多个程序运行的时候,操作系统根据当时内存空闲情况,动态分配一块大小合适的物理内存给程序,所以程序被装载的地址是不确定的。系统在装载程序的时候需要对程序的指令和数据中对绝对地址的引用进行重定位。但这种重定位比前面提到过的静态链接中的重定位要简单得多,因为整个程序是按照一个整体被加载的,程序中指令和数据的相对位置是不会改变的。比如一个程序在编译时假设被装载的目标地址为0x1000,但是在装载时操作系统发现0x1000这个地址已经被别的程序使用了,从0x4000开始有一块足够大的空间可以容纳该程序,那么该程序就可以被装载至0x4000,程序指令或数据中的所有绝对引用只要都加上0x3000的偏移量就可以了。

静态链接时的重定位,叫做链接时重定位(Link Time Relocation),而现在这种情况经常被称为装载时重定位(Load Time Relocation),在Windows中,这种装载时重定位又被叫做基址重置(Rebasing)。

装载时重定位无法解决多个进程共享相同指令的问题。动态链接模块被装载映射至虚拟空间后,指令部分是在多个进程之间共享的,由于装载时重定位的方法需要修改指令,所以没有办法做到同一份指令被多个进程共享,因为指令被重定位后对于每个进程来讲是不同的。当然,动态连接库中的可修改数据部分对于不同的进程来说有多个副本,所以它们可以采用装载时重定位的方法来解决。

Linux和GCC支持这种装载时重定位的方法,在产生共享对象时,一般会用到两个 GCC参数“-shared”和“-fPIC”,如果只使用“-shared”,那么输出的共享对象就是使用装载时重定位的方法。

From #

程序员的自我修养

进程初始化后栈的结构

Content #

Linux的进程初始化后栈的结构,我们假设系统中有两个环境变量:

HOME=/home/user
PATH=/usr/bin

运行该程序的命令行是:

$ prog 123

并且假设堆栈段底部地址为0xBF802000,那么进程初始化后的堆栈就如图所示。 栈顶寄存器esp指向的位置是初始化以后堆栈的顶部,最前面的4个字节表示命令行参数的数量,例子里面是两个,即“prog”和“123”,紧接的就是分布指向这两个参数字符串的指针;后面跟了一个0;接着是两个指向环境变量字符串的指针,它们分别指向字符串“HOME=/home/user”和“PATH=/usr/bin”;后面紧跟一个0表示结束。

进程在启动以后,程序的库部分会把堆栈里的初始化信息中的参数信息传递给 main()函数,也就是我们熟知的main()函数的两个argc和argv两个参数,这两个参数分别对应这里的命令行参数数量和命令行参数字符串指针数组。

From #

程序员的自我修养

vm_area_struct与mm_struct中内存区域的关联

Content #

这个事情是在 load_elf_binary 里面实现的。加载内核的是它,启动第一个用户态进程 init 的是它,fork 完了以后,调用 exec 运行一个二进制程序的也是它。

当 exec 运行一个二进制程序的时候,除了解析 ELF 的格式之外,另外一个重要的事情就是建立内存映射。

static int load_elf_binary(struct linux_binprm *bprm)
{
......
  setup_new_exec(bprm);
......
  retval = setup_arg_pages(bprm, randomize_stack_top(STACK_TOP),
         executable_stack);
......
  error = elf_map(bprm->file, load_bias + vaddr, elf_ppnt,
        elf_prot, elf_flags, total_size);
......
  retval = set_brk(elf_bss, elf_brk, bss_prot);
......
  elf_entry = load_elf_interp(&loc->interp_elf_ex,
              interpreter,
              &interp_map_addr,
              load_bias, interp_elf_phdata);
......
  current->mm->end_code = end_code;
  current->mm->start_code = start_code;
  current->mm->start_data = start_data;
  current->mm->end_data = end_data;
  current->mm->start_stack = bprm->p;
......
}

load_elf_binary 会完成以下的事情:

...

mm_struct中控制内存区域属性的成员

Content #

除了位置信息之外,struct mm_struct 里面还专门有一个结构 vm_area_struct,来描述这些区域的属性。

struct vm_area_struct *mmap;    /* list of VMAs */
struct rb_root mm_rb;

这里面一个是单链表,用于将这些区域串起来。另外还有一个红黑树。又是这个数据结构,在进程调度的时候我们用的也是红黑树。它的好处就是查找和修改都很快。这里用红黑树,就是为了快速查找一个内存区域,并在需要改变的时候,能够快速修改。

From #

vm_area_struct成员

Content #

struct vm_area_struct {
  /* The first cache line has the info for VMA tree walking. */
  unsigned long vm_start;    /* Our start address within vm_mm. */
  unsigned long vm_end;    /* The first byte after our end address within vm_mm. */
  /* linked list of VM areas per task, sorted by address */
  struct vm_area_struct *vm_next, *vm_prev;
  struct rb_node vm_rb;
  struct mm_struct *vm_mm;  /* The address space we belong to. */
  struct list_head anon_vma_chain; /* Serialized by mmap_sem &
            * page_table_lock */
  struct anon_vma *anon_vma;  /* Serialized by page_table_lock */
  /* Function pointers to deal with this struct. */
  const struct vm_operations_struct *vm_ops;
  struct file * vm_file;    /* File we map to (can be NULL). */
  void * vm_private_data;    /* was vm_pte (shared mem) */
} __randomize_layout;

vm_start 和 vm_end 指定了该区域在用户空间中的起始和结束地址。 vm_next 和 vm_prev 将这个区域串在链表上。 vm_rb 将这个区域放在红黑树上。 vm_ops 里面是对这个内存区域可以做的操作的定义。

...