《程序员的自我修养》番外笔记——符号解析与重定位

  • 程序如下:

重定位

  • 先来看这段代码的反汇编结果。

  • "main"的起始地址为0x00000000,这是因为在未进行空间分配之前,目标文件代码段中的起始地址以0x00000000开始,等到空间分配完成以后,各个函数才会确定自己在虚拟地址空间中的位置。
  • 偏移为0x18的地址上是一条mov指令,总共8个字节,它的作用是将“shared”的地址赋值到esp寄存器+4的偏移地址中去,前面4个字节“c7442404”是mov的指令码,后面4个字节是“shared”的地址。
  • 偏移为0x26的地址上是一条调用指令,它表示对swap函数的调用。这条指令共5个字节,前面的0xe8是操作码,这是一条近址相对位移调用指令,后面4个字节就是被调用函数的相对于调用指令的下一条指令的偏移量。在没有重定位之前,相对偏移被置为0xFFFFFFFC(小端),它是常量“-4”的补码形式。

重定位表

  • 对于可重定位的ELF文件来说,它必须包含有重定位表,用来描述如何修改相应的段里的内容。对于每个要重定位的ELF段都有一个对应的重定位表,而一个重定位表往往就是ELF文件中的一个段,所以其实重定位表也可以叫重定位段。
  • 通过命令可以查看目标文件的重定位表。

  • OFFSET是重定位的入口偏移,表示该入口在要被重定位的段中的位置。“.text”表示这个重定位表示代码段的重定位表,所以偏移表示代码段中需要被调整的位置。这里的0x1c和0x27分别就是代码段中“mov”指令和“call”指令的地址部分

符号解析

  • 重定位过程也伴随着符号的解析过程,每个目标文件都可能定义一些符号,也可能引用到定义在其他目标文件的符号。重定位的过程中,每个重定位的入口都是对一个符号的引用,那么当链接器需要对某个符号的引用进行重定位时,它就要确定这个符号的目标地址。这时候链接器就会去查找由所有输入目标文件的符号表组成的全局符号表,找到相应的符号后进行重定位
  • 通过命令查看“a.o”的符号表。

  • 可以看到shared和swap的类型都是“UND”,即“undefined”未定义类型,在链接器扫描完所有的输入目标文件后,所有这些未定义的符号都应该能够在全局符号表中找到,否则链接器就报符号未定义错误。这种一般都是链接时缺少了某些库,或者输入目标文件路径不正确或符号的声明与定义不一样。

指令修改方式

  • 不同的处理器指令对于地址的格式和方式都不一样。
  • 对于32位x86平台下的ELF文件的重定位入口所修正的指令寻址方式只有两种:

    • 绝对近址32位寻址。
    • 相对近址32位寻址。
  • 这两种重定位方式指令修正方式每个被修正的位置的长度都是32位。
  • 这两种方式的定义:

  • 通过前面的重定位表可以看到swap符号的类型为R_386_PC32,这是一条相对位移调用指令。而shared符号的类型为R_386_32,它修正的是一条传输指令的源,即shared的绝对地址。
  • 假设在将a.o和b.o链接成最终可执行文件后,main函数的虚拟地址为0x1000,swap函数的虚拟地址为0x2000,shared变量的虚拟地址为0x3000。
  • 首先看偏移为0x18的这条mov指令的修正,它是绝对寻址修正,它修正后的结果是S+A。

    • S是符号shared的实际地址,即0x3000。
    • A是被修正位置的值,即0x00000000。
  • 所以它的修正后的地址为:0x3000+0x00000000=0x3000。

  • 再来看偏移为0x26的这条call指令的修正,它是相对寻址修正,它修正后的结果是S+A-P。

    • S是符号swap的实际地址,即0x2000。
    • A是被修正位置的值,即0xFFFFFFFC(-4)。
    • P为被修正的位置,当链接成可执行文件时,这个值应该是被修正位置的虚拟地址,即0x1000+0x27。
  • 所以它的修正后的地址为0x2000+(-4)-(0x1000+0x27)=0xFD5。

  • 这条相对位移调用指令的调用地址是该指令下一条指令的起始地址加上偏移量,即:0x102b+0xfd5=0x2000,刚好是swap函数的地址。
  • 从这两个例子可以看出来,绝对寻址修正和相对寻址修正的区别就是绝对寻址修正后的地址为该符号的实际地址;相对寻址修正后的地址为符号距离被修正位置的地址差

one more thing!

C语言标准库中的变长参数

  • 变长参数是C语言的特殊参数形式,比如printf的声明:
int printf(const char* format, ...);
  • printf函数除了第一个参数类型为const char*之外,其后可以追加任意数量、任意类型的参数。
  • 变长参数的实现得益于C语言默认的cdecl调用惯例的自右向左压栈传递方式。
  • 首先,看这样一个函数。
// 第一个参数传递一个整数num,紧接着后面会传递num个整数,返回num个整数的和。
int sum(int num, ...);
  • 当我们调用:”int n = sum(3, 16, 38, 53);“时,参数在栈上的布局会是这样的。

  • 在函数内部,函数可以使用名称num来访问数字3,当无法使用任何名称访问其他的几个不定参数。当此时由于栈上其他的几个参数实际恰好依序排列在参数num的高地址方向,因此可以简单地通过num的地址计算出其他参数的地址。
// sum的实现
int sum(int num, ...) {
int *p = &num + 1;
int ret = 0;
while (num--)
  ret += *p++;
return ret;
}
  • printf的不定参数比sum要复杂很多,因为printf的参数不仅数量不定,而且类型也不定。所以printf需要在格式字符串中注明参数类型。printf里的格式字符串如果将类型描述错误,因为不同参数的大小不同,不仅可能导致这个参数的输出出错,还有可能导致其后的一系列参数错误。

你可能感兴趣的:(程序员,pe,编译,printf)