计算机原理系列之五 ——– 编译过程分析

如何编译目标文件中我们了解到汇编文件经过编译器之后生成了可重定位文件,并且在可重定位文件详解中我们以一个最简单的'hello, wolrd'程序为例,分析了可重定位文件的详细内容。这篇文章中我们主要分析一下汇编文件和可重定位文件代码段的关系,以及从汇编文件生成可重定位文件的详细过程。

我们还是以如何编译目标文件中的hello.c来研究。

一、 由汇编器生成的汇编代码

首先我们先查看一下经过汇编之后转换成的汇编代码:

        .file   "hello.c"
        .section        .rodata
.LC0:
        .string "hello, world"
        .text
        .globl  main
        .type   main, @function
main:
.LFB0:
        pushq   %rbp
        movq    %rsp, %rbp
        movl    $.LC0, %edi
        call    puts
        movl    $0, %eax
        popq    %rbp
        ret
.LFE0:
        .size   main, .-main
        .ident  "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.10) 5.4.0 20160609"
        .section        .note.GNU-stack,"",@progbits

为了方便阅读代码中我们省略了CFI信息,关于CFI详细信息,请参考参考阅读[1]

二、 查看可重定位文件的内容

2.1 可重定位文件的实际内容

由于可重定位文件属于二进制文件。在linux机器上,可以使用hexdump命令来查看二进制文件的内容。

$ hexdump -C hello.o
00000000  7f 45 4c 46 02 01 01 00  00 00 00 00 00 00 00 00  |.ELF............|
00000010  01 00 3e 00 01 00 00 00  00 00 00 00 00 00 00 00  |..>.............|
00000020  00 00 00 00 00 00 00 00  a0 02 00 00 00 00 00 00  |................|
00000030  00 00 00 00 40 00 00 00  00 00 40 00 0d 00 0a 00  |....@.....@.....|
00000040  55 48 89 e5 bf 00 00 00  00 e8 00 00 00 00 b8 00  |UH..............|
00000050  00 00 00 5d c3 68 65 6c  6c 6f 2c 20 77 6f 72 6c  |...].hello, worl|
00000060  64 00 00 47 43 43 3a 20  28 55 62 75 6e 74 75 20  |d..GCC: (Ubuntu |
00000070  35 2e 34 2e 30 2d 36 75  62 75 6e 74 75 31 7e 31  |5.4.0-6ubuntu1~1|
00000080  36 2e 30 34 2e 31 30 29  20 35 2e 34 2e 30 20 32  |6.04.10) 5.4.0 2|
00000090  30 31 36 30 36 30 39 00  14 00 00 00 00 00 00 00  |0160609.........|

... snip ...

2.2 反汇编可重定位的代码段

hello.o是一个二进制文件,显然,代码段也被编译成了一系列的二进制数字,而且这些数字就被包含于上面hexdump输出的数字里,那么如何找到他们呢?

我们知道ELF文件中,Section Header Table指定了各个section的起始地址,长度等信息,那么,我们查看hello.o的信息得到以下内容:

$ readelf -S hello.o
There are 13 section headers, starting at offset 0x2a0:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .text             PROGBITS         0000000000000000  00000040
       0000000000000015  0000000000000000  AX       0     0     1

... snip ...

上述信息我们知道.text(代码段)起始于ELF文件首地址偏移00000040处,长度为0x15个字节。也即0x40 ~ 0x54之间都为代码段部分,结合hexdump输出的信息,即以下内容:

00000040  55 48 89 e5 bf 00 00 00  00 e8 00 00 00 00 b8 00  |UH..............|
00000050  00 00 00 5d c3                                    |...]

这些二进制数据显然不那么的友好,我们可以使用objdump命令来反编译一下。objdump工具用来显示二进制文件的信息,就是以一种可阅读的格式让你更多地了解二进制文件可能带有的附加信息。-d参数表示反汇编ELF文件中可执行的代码(代码段)部分。

$ objdump -d hello.o

hello.o:     file format elf64-x86-64


Disassembly of section .text:

0000000000000000 <main>:
   0:   55                      push   %rbp
   1:   48 89 e5                mov    %rsp,%rbp
   4:   bf 00 00 00 00          mov    $0x0,%edi
   9:   e8 00 00 00 00          callq  e <main+0xe>
   e:   b8 00 00 00 00          mov    $0x0,%eax
  13:   5d                      pop    %rbp
  14:   c3                      retq

输出内容中最左侧依然是字节序列的序号,中间部分表示ELF文件内保存的字节序列,最右侧是反汇编出来的汇编代码。我们注意到,反编译出的字节序列正好就是根据hexdump命令和section header table推算出来的字节序列。

仔细观察发现,objdump反汇编出来的代码中除了省略掉了.cfi_*相关的信息,和原来的汇编文件的代码部分唯一的差异有两条指令。摘录如下:

# 汇编器生成的汇编代码:
        movl    $.LC0, %edi
        call    puts

# 反编译出来的汇编代码:
   4:   bf 00 00 00 00          mov    $0x0,%edi
   9:   e8 00 00 00 00          callq  e <main+0xe>

首先,我们分析第二条语句,结合C代码这行汇编代码对应的是printf("hello, world\n");。这里我们不用在意为什么C代码中调用的是printf函数,而到汇编语言中却变成了puts函数(实际上是编译器做了优化)。我们主要讨论为什么由汇编器生成的call指令和反编译出来的call指令会有区别。

实际上,由汇编器生成的call指令,仅仅是将函数名替代进来了而已。而反编译出来的call指令后面的值(本例中'callq e'中的'e')其实只是个占位符,并无实际意义。当一个模块(本例中的hello.o)调用一个外部的函数(本例中printfprintf并未在hello.c中定义)时,那么编译器会生成一条call指令,指令的操作数是一个占位符,而与此同时生成一条重定位条目(relocation entry)放在.rel.text section中,类似的,当模块调用一个外部变量时,编译器会生成一条重定位条目放在.rel.data section中。我们可以在可重定位文件详解 2.2.2的第9条中看到put对应的重定位条目。

其次,我们再看第一条语句,在.s文件中这条语句的意思是将.LC0的地址复制到%edi中去。结合代码,我们知道.LC0保存的是”hello, world\n”字符串的地址,将其复制到edi中去。根据x86_64体系结构,在调用某个函数时,其第一个参数、第二个参数、第三个参数、第四个参数应该依次保存到%edi、%esi、%edx、%ecx中。 那么这条语句在编译成.o文件反汇编出来后,对应参数地址为什么变成0了呢?

实际上,同上面call指令一样,0在此处也是一个占位符。同时,在.rel.text中也生成了一条重定位条目。

三、总结

我们再回过头来总结一下,从汇编语言的.s文件到二进制的.o文件,是怎么生成的。

  1. .s文件中包含外部函数的汇编指令,是用操作符加函数名来表示的,经过编译之后,生成的.o文件中,对应指令中的函数名被占位符所代替;

  2. .s文件中包含全局变量的汇编指令,是用操作符加变量名来表示的,经过编译之后,生成的.o文件中,对应指令中的变量名被转换成了由RIP寄存器表示的位置无关码,其中,加在RIP上的偏移量也是一个占位符;

  3. .s文件中包含常量的汇编指令,是用操作符加变量名来表示的,经过编译之后,生成的.o文件中,对应指令中的常量名被转换成了占位符;

  4. .s文件中其他指令被保留下来生成了.o文件的.text段中;

  5. 编译器为上述的每个外部函数生成了对应的重定位条目,放到了.o文件的.rel.text中,为上述的每个全局变量生成了对应的重定位条目,放到了.o文件的.rel.data中;

  6. .s文件中已初始化的全局变量和静态变量被保存在了.o文件的.data段中;

  7. 原来c文件中的未初始化的全局变量和静态变量,初始化为0的全局变量和静态变量被保存到了.o文件的.bss段中;

  8. .s文件中的只读数据生成了.o文件的.rodata段;

  9. 编译器信息以ASCII字符的形式保存到了.o文件的.comment段中;

  10. .s文件中所有的CFI相关的信息被保存到了.eh_frame段中,并且对.eh_frame段生成了对应的重定位信息,保存到了.rela.eh_frame中;

  11. .s文件中文件名,全局变量和外部函数的名字被保存到了.strtab段中;

  12. 在由.s文件生成.o文件时,生成的各个段的名字被保存到了.shstrtab中。

综上所述,我们用一个更直观的gif图片来反映从汇编到.o文件的过程:
compile
点击此处查看原图

四、参考阅读

  1. CFI指令
    1) 汇编代码中的CFI指令
    2) CFI support for GNU assembler
    3) CFI-directives
    4) GAS: Explanation of .cfi_def_cfa_offset

发表评论

电子邮件地址不会被公开。 必填项已用*标注