编译连接是如何获得可执行文件的呢?

盘古开天辟地!咱们写了个C语言源文件,那从源文件到可执行程序这中间又发生了什么?编译,连接这些概念又是什么意思?带着对这些问题的好奇,我查了一些资料。其中,主要参考的是《程序员的自我修养》这本书和一些网上的博客。linux

windows下常常只须要单击Run或者Debug就能够运行一个C语言程序,这种便利隐藏了背后的复杂机制,而我想知道这背后到底发生了什么。c++

本文所使用的系统是ubuntu,但这些概念也适用于windows下。程序员

1. 编译源文件的四个阶段

假如咱们写了一个很简单的helloworld.c程序:ubuntu

#include <stdio.h>

int main(int argc, char *argv[])
{
    printf("Hello,World!\n");
    return 0;
}

咱们都知道运行命令windows

gcc helloworld.c -o helloworld

即可以对这个文件进行编译,并命名可执行文件为helloworld。而后运行sass

./helloworld
Hello,World!

即可以执行该文件,可是这背后又经历了什么呢?bash

注意:函数

本文并非一篇严谨的探讨编译过程的文章,只是我对这个问题了解过程的一个梳理。code

1.1 预处理(preprocessing)

在预处理阶段,咱们能够简单理解就是处理以"#"开始的那些预处理指令,好比说:ip

#define,#include,#if,#elif,#else,#endif

预处理器会按照这些指令的意义进行处理,将#define定义的宏进行替换展开,将#include包含的文件总体替换进来。

能够运行命令

gcc -E helloworld.c -o helloworld.i

来获得通过预处理后的文件,检查能够发现预处理确实帮咱们把#include的文件包含进来了,另外在文件中还包含了一些行号信息,以便以后程序出错提示错误所在的位置。

1.2 编译(compile)

这一步是将上一步获得的*.i进行编译,获得汇编代码,能够运行命令

gcc -S helloworld.i -o helloworld.s

来获得通过汇编后的文件,该文件的其中一部分以下:

main:
    ...
    leaq    .LC0(%rip), %rcx
    call    puts
    ...

正好对应咱们在主程序中调用的函数printf,因而咱们知道在这一步是生成了汇编文件。

1.3 汇编(assembly)

这一步是将上一步的汇编代码汇编为具体的机器代码,能够运行命令

gcc -c helloworld.s -o helloworld.o

生成的helloworld.o能够称为目标文件,下面咱们对目标文件来检查,帮助理解连接过程。

1.3.1 目标文件的结构

上一步中生成的是目标文件,但这个目标文件尚未通过连接,也就是它其中的一些符号还没法肯定,好比说在上面的printf咱们就没法肯定在哪里去寻找这个函数的具体定义,经过头文件stdio.h咱们只是知道了它的定义形式,知道如何去调用它,可是具体执行的时候是须要代码的,那么去哪里找呢?寻找printf并将它的地址写入到咱们的程序中就是连接的做用。

咱们在系统中常常打交道的文件有

  1. 可执行文件(Executable File),好比Windows下的.exe,或者linux/bin/bash文件
  2. 共享目标文件(Shared Object File),好比Windows下的.dll,或者linux.so文件
  3. 可重定位文件(Relocatable File),咱们上面生成的文件即是这种文件,可重定位指的是程序中的一些位置的符号(函数名,变量名)的地址还未肯定,在以后的连接阶段须要从新定位

Linux下可使用命令file来查看文件的具体格式,让咱们运行

$ file helloworld.o
helloworld.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

那么具体来讲,目标文件到底包含什么呢?首先必定会包含代码,其次是数据(定义的变量),除此之外,咱们还关心的是文件中包含的符号表,它是咱们后续执行连接最重要的内容了。

运行命令

$ readelf -S helloworld.o

能够查看咱们目标文件的段表,关于段表的详细介绍请查看《程序员的自我修养》这本书。

There are 13 section headers, starting at offset 0x2d8:

节头:
  [号] 名称              类型             地址              偏移量
       大小              全体大小          旗标   连接   信息   对齐
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .text             PROGBITS         0000000000000000  00000040
       0000000000000022  0000000000000000  AX       0     0     1
  [ 2] .rela.text        RELA             0000000000000000  00000228
       0000000000000030  0000000000000018   I      10     1     8
  [ 3] .data             PROGBITS         0000000000000000  00000062
       0000000000000000  0000000000000000  WA       0     0     1
  [ 4] .bss              NOBITS           0000000000000000  00000062
       0000000000000000  0000000000000000  WA       0     0     1
  [ 5] .rodata           PROGBITS         0000000000000000  00000062
       000000000000000d  0000000000000000   A       0     0     1
  [ 6] .comment          PROGBITS         0000000000000000  0000006f
       000000000000002c  0000000000000001  MS       0     0     1
  [ 7] .note.GNU-stack   PROGBITS         0000000000000000  0000009b
       0000000000000000  0000000000000000           0     0     1
  [ 8] .eh_frame         PROGBITS         0000000000000000  000000a0
       0000000000000038  0000000000000000   A       0     0     8
  [ 9] .rela.eh_frame    RELA             0000000000000000  00000258
       0000000000000018  0000000000000018   I      10     8     8
  [10] .symtab           SYMTAB           0000000000000000  000000d8
       0000000000000120  0000000000000018          11     9     8
  [11] .strtab           STRTAB           0000000000000000  000001f8
       000000000000002e  0000000000000000           0     0     1
  [12] .shstrtab         STRTAB           0000000000000000  00000270
       0000000000000061  0000000000000000           0     0     1

咱们关心的是上述段表中的2号段表:.rela.text可重定位表。正如咱们以前所说的,在连接阶段要对可重定位文件中的一些符号进行重定位,因此咱们必须了解哪些符号须要进行定位,而.rela.text就是用来记录相应的符号。

其中,符号表中会包含几种符号:

  1. 在本文件中定义的符号,能够被其它目标文件所引用
  2. 在本文件中引用的符号,但却没有在本文件中定义
  3. ...

咱们先运行命令

$ nm helloworld.o
                 U _GLOBAL_OFFSET_TABLE_
0000000000000000 T main
                 U puts

来查看咱们的目标文件中的符号表,能够看到咱们两个符号mainputs之因此不是printf多是编译中进行了改变。

让咱们运行另一个命令来详细查看符号表:

$ readelf -s helloworld.o
Symbol table '.symtab' contains 12 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     ......
     9: 0000000000000000    34 FUNC    GLOBAL DEFAULT    1 main
    10: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND _GLOBAL_OFFSET_TABLE_
    11: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND puts

又看到了咱们熟悉的两个符号,因为main是在本文件中定义的因此它的类型是FUNC函数,且Ndx=1能够得知位于代码段,而puts因为未定义,因此Ndx=UND(undefine),所以经过符号表咱们即可以得到哪些符号是在本文件中定义的,哪些符号是须要进行重定位的。

1.4 连接(link)

上面咱们知道了符号表的存在,下面咱们详细说明下连接的过程。

假设咱们有了两个文件,a.cb.c。例子来自于《程序员的自我修养》。

/* a.c */
extern int shared;
int main(){
    int a=100;
    swap(&a, &shared);
    return 0;
}

/* b.c */
int shared = 1; // default is global variable, can be accessed by external program

void swap(int *a, int *b){
    *a ^= *b ^= *a ^= *b; // swap value
}

首先使用gcc编译这两个文件

$ gcc -c a.c b.c

而后咱们会获得两个文件a.ob.o,分别查看这两个文件的符号表

$ readelf -s a.o
Symbol table '.symtab' contains 13 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     ......
     8: 0000000000000000    81 FUNC    GLOBAL DEFAULT    1 main
     9: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND shared
    11: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND swap
    
$ readelf -s b.o
Symbol table '.symtab' contains 10 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     ......
     8: 0000000000000000     4 OBJECT  GLOBAL DEFAULT    2 shared
     9: 0000000000000000    75 FUNC    GLOBAL DEFAULT    1 swap

因而,咱们能够看出,在a.o中只定义了一个全局符号main,而sharedswap都是未定义,而在b.o中,sharedswap则是定义了的。

咱们将采用的连接命令为

$ ld a.o b.o -e main -o ab
  • -e 表示main做为主函数入口
  • -o 表示输出文件名

而后查看分配先后地址的分配状况

$ objdump -h a.o
a.o:     文件格式 elf64-x86-64

节:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         00000051  0000000000000000  0000000000000000  00000040  2**0
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
  1 .data         00000000  0000000000000000  0000000000000000  00000091  2**0
                  CONTENTS, ALLOC, LOAD, DATA
  ......

$ objdump -h b.o
b.o:     文件格式 elf64-x86-64
节:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         0000004b  0000000000000000  0000000000000000  00000040  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .data         00000004  0000000000000000  0000000000000000  0000008c  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  ......

我尝试了好几遍运行命令

$ ld a.o b.o -e main -o ab

可是都提示一个错误

a.o:在函数‘main’中:
a.c:(.text+0x4b):对‘__stack_chk_fail’未定义的引用

不知道为何,因而我只好使用命令

$ gcc a.o b.o -o ab

可是生成后的文件和做者的就不太同样了,以下

节:
 Idx Name          Size      VMA               LMA               File off  Algn
 ......
 13 .text         00000222  0000000000000560  0000000000000560  00000560  2**4
 ......
 22 .data         00000014  0000000000201000  0000000000201000  00001000  2**3
                  CONTENTS, ALLOC, LOAD, DATA
 23 .bss          00000004  0000000000201014  0000000000201014  00001014  2**0
                  ALLOC
 24 .comment      0000002b  0000000000000000  0000000000000000  00001014  2**0
                  CONTENTS, READONLY

可是仍然是能够看出VMA(虚拟内存地址)已经被赋值了,而在以前的a.ob.o中都是没有赋值的。

到这一步的意思是通过连接,咱们将两个目标文件合成到一个文件中了,而且每一个函数都有本身的相对地址,这时候咱们就能够给每个符号赋予地址了。

运行命令

$ readelf -s ab

来查看符号表,只列出相关的内容

Symbol table '.symtab' contains 66 entries:
    Num:    Value          Size Type    Bind   Vis      Ndx Name
    59: 000000000000066a    81 FUNC    GLOBAL DEFAULT   14 main

    62: 00000000000006bb    75 FUNC    GLOBAL DEFAULT   14 swap

    65: 0000000000201010     4 OBJECT  GLOBAL DEFAULT   23 shared

咱们能够看出相关符号已经被赋予了具体的地址空间,也就是咱们完成了连接过程。

在完成上述过程后,咱们运行命令来反汇编查看

$ objdump -d ab
 000000000000066a <main>:
 66a:    55                       push   %rbp
 66b:    48 89 e5                 mov    %rsp,%rbp
 66e:    48 83 ec 10              sub    $0x10,%rsp
 672:    64 48 8b 04 25 28 00     mov    %fs:0x28,%rax
 679:    00 00 
 67b:    48 89 45 f8              mov    %rax,-0x8(%rbp)
 67f:    31 c0                    xor    %eax,%eax
 681:    c7 45 f4 64 00 00 00     movl   $0x64,-0xc(%rbp)
 688:    48 8d 45 f4              lea    -0xc(%rbp),%rax
 68c:    48 8d 35 7d 09 20 00     lea    0x20097d(%rip),%rsi        # 201010 <shared>
 693:    48 89 c7                 mov    %rax,%rdi
 696:    b8 00 00 00 00           mov    $0x0,%eax
 69b:    e8 1b 00 00 00           callq  6bb <swap> # <swap> 6bb
 6a0:    b8 00 00 00 00           mov    $0x0,%eax
 6a5:    48 8b 55 f8              mov    -0x8(%rbp),%rdx
 6a9:    64 48 33 14 25 28 00     xor    %fs:0x28,%rdx
 6b0:    00 00 
 6b2:    74 05                    je     6b9 <main+0x4f>
 6b4:    e8 87 fe ff ff           callq  540 <__stack_chk_fail@plt>
 6b9:    c9                       leaveq 
 6ba:    c3                       retq

注意到swap以及变量shared的地址已经被正确地赋值给了程序,做为对比咱们查看下在连接以前程序的内容

$ objdump -d a.o
a.o:     文件格式 elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
   0:    55                       push   %rbp
   1:    48 89 e5                 mov    %rsp,%rbp
   4:    48 83 ec 10              sub    $0x10,%rsp
   8:    64 48 8b 04 25 28 00     mov    %fs:0x28,%rax
   f:    00 00 
  11:    48 89 45 f8              mov    %rax,-0x8(%rbp)
  15:    31 c0                    xor    %eax,%eax
  17:    c7 45 f4 64 00 00 00     movl   $0x64,-0xc(%rbp)
  1e:    48 8d 45 f4              lea    -0xc(%rbp),%rax
  22:    48 8d 35 00 00 00 00     lea    0x0(%rip),%rsi        # 29 <main+0x29>
  29:    48 89 c7                 mov    %rax,%rdi
  2c:    b8 00 00 00 00           mov    $0x0,%eax
  31:    e8 00 00 00 00           callq  36 <main+0x36>
  36:    b8 00 00 00 00           mov    $0x0,%eax
  3b:    48 8b 55 f8              mov    -0x8(%rbp),%rdx
  3f:    64 48 33 14 25 28 00     xor    %fs:0x28,%rdx
  46:    00 00 
  48:    74 05                    je     4f <main+0x4f>
  4a:    e8 00 00 00 00           callq  4f <main+0x4f>
  4f:    c9                       leaveq 
  50:    c3                       retq

咱们要注意的是偏移22和偏移31分别对应着sharedswap的调用,而第二列的十六进制表明这条指令,每一个指令的后四个字节为地址,能够看出这些地址都是0,这说明在文件a.o中,因为没法肯定具体的地址,此时编译器只是将其赋了一个特殊的地址0x0,而后在最后的连接阶段再完成正确的地址赋值。

咱们还能够运行命令

$ objdump -r a.o
a.o:     文件格式 elf64-x86-64

RELOCATION RECORDS FOR [.text]:
OFFSET           TYPE              VALUE 
0000000000000025 R_X86_64_PC32     shared-0x0000000000000004
0000000000000032 R_X86_64_PLT32    swap-0x0000000000000004
000000000000004b R_X86_64_PLT32    __stack_chk_fail-0x0000000000000004

其中的offset描述了要重定位的位置。

2. 总结

事实上,在《程序员的自我修养》这本书中做者对于细节的探讨很深刻,要想彻底理解掌握实在太难。

我主要想总结下关于连接部分。大概的过程就是:

  1. 连接器接收到输入文件
  2. 收集每一个输入文件的段表,合成一个全局符号表,这张表里包含全部定义的符号
  3. 若是是静态连接,将多个输入文件合并,进行地址空间的分配,在这一步完成以后全部符号的具体地址就定了
  4. 而后再对每个输入文件中须要重定位的符号从新定位到正确的地址处
相关文章
相关标签/搜索