一个C / C++程序到底是如何从磁盘上的源码文件变为可执行文件的?一个可执行文件又是如何变为进程运行在操作系统中的?

简单的事物背后往往蕴含着非常复杂的机制。本文就来讲讲这些屠龙之技。

一、GCC干了什么

#include <stdio.h>
 
int main() {
    printf("Hello World\n");
    return 0;
}

在 Linux 下,使用 GCC 来编译这个 Hello World 程序,只需要执行:

$ gcc hello.c -o hello.out
$ ./hello.out
Hello World

这个过程中,gcc 到底扮演了什么角色?

预处理

执行

$ gcc -E hello.c -o hello.i

或者

$ cpp hello.c > hello.i

即可得到预编译后的 .i 文件。预编译阶段,预编译器主要任务是:

  • 展开所有宏定义
  • 处理所有条件预编译指令(#if等)
  • 处理 #include 预编译指令,将被包含的文件插入到该指令位置
  • 删除所有的注释
  • 添加行号和文件名标识,用于编译器产生错误时有行号信息等

编译

编译过程就是讲预处理后的文件进行一系列处理后,产生相应的汇编代码文件。执行

$ gcc -S hello.i -o hello.s

汇编

汇编器是将汇编代码转变为机器可以执行的指令,得到目标文件(Object File),执行

$ gcc -c hello.s -o hello.o

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

链接

汇编器输出的是一个目标文件而不是可执行文件。需要通过链接器对目标文件进行处理才能得到可执行文件,如果直接执行:

$ ld hello.o

得到如下结果:

ld: warning: cannot find entry symbol _start; defaulting to 00000000004000e8
hello.o(.text+0xf): In function `main':
: undefined reference to `printf'

报错提示我们,程序中找不到 printf 这个符号的定义。实际上,我们知道,printf 函数是定义在其他文件中,我们回过头看 hello.s的一个片段:

.LC0:
    .string "Hello World\n"
    .text
 
...
 
.LCFI1:
    movl    $.LC0, %edi
    movl    $0, %eax
    call    printf
    movl    $0, %eax
    leave
    ret

瞧,printf 的地址没有被确定!这就是链接器的任务。链接器在链接的时候,会根据目标文件中所引用的符号,去相应的模块里查找对应的地址,然后将所有对这个符号的指令重新修正,这就是静态链接的过程和作用。

$ file hello.out
hello.out: ELF 64-bit LSB executable, AMD x86-64, version 1 (SYSV),
for GNU/Linux 2.4.0, dynamically linked (uses shared libs), not stripped

现在回过头来回答标题的问题,gcc干了什么呢?

实际上,gcc这个命令只是预处理器(cpp)、编译器(cc1)、汇编器(as)、链接器(ld)这些后台程序的包装,gcc会根据不同的参数去调用相应的子程序,最终完成从程序源码到可执行文件这个过程。

二、目标文件里是什么

目标文件就是源代码编译后但未进行链接的那些中间文件(Linux下的 .o 文件),它与可执行文件的内容和结构非常相似。在 Linux 下,我们可以将它们统称为 ELF(Executable Linkable Format)文件。

ELF文件类型 实例
可重定位文件 Linux 下的 .o 文件
可执行文件 a.out
共享目标文件 Linux 下的 .so 文件
核心转储文件 Linux下的 core dump

本节以 SimpleSection.c 作为分析对象。

int printf(const char *format, ...);
int global_init_var = 84;
int global_uninit_var;
void func1(int i) {
    printf("%d\n", i);
}
int main() {
    static int static_var = 85;
    static int static_var_2;
    int a = 1;
    int b;
    func1(static_var + static_var_2 + a + b);
    return a;
}

ELF 文件结构

ELF Header(文件头)
.text
.data
.bss
…other sections
Section header table(段表)
Symbol Table(符号表)
String Tables(字符串表)

ELF文件最开始的是ELF文件头,接着是ELF文件中的各个段,然后是描述各个段信息的段表,之后就是ELF中辅助的结构(字符串表、符号表)。

ELF 文件头

使用 readelf -h SimpleSection.o 得到ELF文件头内容如下:

ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00              ELF 魔数
  Class:                             ELF64                              文件机器字节长度
  Data:                              2's complement, little endian      数据存储方式
  Version:                           1 (current)                        版本
  OS/ABI:                            UNIX - System V                    运行平台
  ABI Version:                       0                                  ABI 版本
  Type:                              REL (Relocatable file)             ELF 重定位类型
  Machine:                           Advanced Micro Devices X86-64      硬件平台
  Version:                           0x1                                硬件平台版本
  Entry point address:               0x0                                入口地址
  Start of program headers:          0 (bytes into file)                程序头入口
  Start of section headers:          384 (bytes into file)              段表的位置
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           0 (bytes)
  Number of program headers:         0
  Size of section headers:           64 (bytes)
  Number of section headers:         13                                 段的数量
  Section header string table index: 10

段表

使用 readelf -S SimpleSection.o 命令,可以得到段表如下:

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    000000000000004c  0000000000000000  AX       0     0     4
  [ 2] .rela.text        RELA             0000000000000000  000006a8    0000000000000078  0000000000000018          11     1     8
  [ 3] .data             PROGBITS         0000000000000000  0000008c    0000000000000008  0000000000000000  WA       0     0     4
  [ 4] .bss              NOBITS           0000000000000000  00000094    0000000000000004  0000000000000000  WA       0     0     4
  [ 5] .rodata           PROGBITS         0000000000000000  00000094    0000000000000004  0000000000000000   A       0     0     1
  [ 6] .eh_frame         PROGBITS         0000000000000000  00000098    0000000000000058  0000000000000000   A       0     0     8
  [ 7] .rela.eh_frame    RELA             0000000000000000  00000720    0000000000000030  0000000000000018          11     6     8
  [ 8] .note.GNU-stack   PROGBITS         0000000000000000  000000f0    0000000000000000  0000000000000000           0     0     1
  [ 9] .comment          PROGBITS         0000000000000000  000000f0    000000000000002d  0000000000000000           0     0     1
  [10] .shstrtab         STRTAB           0000000000000000  0000011d    0000000000000061  0000000000000000           0     0     1
  [11] .symtab           SYMTAB           0000000000000000  000004c0    0000000000000180  0000000000000018          12    11     8
  [12] .strtab           STRTAB           0000000000000000  00000640    0000000000000061  0000000000000000           0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings)
  I (info), L (link order), G (group), x (unknown)
  O (extra OS processing required) o (OS specific), p (processor specific)

由段表就可以得出SimpleSection.o所有段的位置和长度了:

SimpleSection.jpeg

重定位表

从段表中可以看到,有一个”.rel.text“的段,TYPE 为 RELA,也就是说它是一个重定位表。重定位表会记录重定位的信息。

每个需要重定位的代码段或数据段都有一个重定位表。

字符串表

字符串表的段名一般为 ”.strtab“ 或 ”.shstrtab”。这两个字符串表分别为 字符串表 和 段表字符串表。

字符串表用于保存普通的字符串,如符号的名字。段表字符串表用来保存段表中用到的字符串,最常见的就是段名。

符号表

ELF 文件中的符号表是文件中的一个段,段名为 “.symtab”。 readelf -s SimpleSection.o 可以查看符号表信息。

Symbol table '.symtab' contains 16 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS SimpleSection.c
     2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1
     3: 0000000000000000     0 SECTION LOCAL  DEFAULT    3
     4: 0000000000000000     0 SECTION LOCAL  DEFAULT    4
     5: 0000000000000000     0 SECTION LOCAL  DEFAULT    5
     6: 0000000000000004     4 OBJECT  LOCAL  DEFAULT    3 static_var.0
     7: 0000000000000000     4 OBJECT  LOCAL  DEFAULT    4 static_var_2.1
     8: 0000000000000000     0 SECTION LOCAL  DEFAULT    6
     9: 0000000000000000     0 SECTION LOCAL  DEFAULT    8
    10: 0000000000000000     0 SECTION LOCAL  DEFAULT    9
    11: 0000000000000000     4 OBJECT  GLOBAL DEFAULT    3 global_init_var
    12: 0000000000000000    31 FUNC    GLOBAL DEFAULT    1 func1
    13: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND printf
    14: 000000000000001f    45 FUNC    GLOBAL DEFAULT    1 main
    15: 0000000000000004     4 OBJECT  GLOBAL DEFAULT  COM global_uninit_var

其他段

使用 objdump -h SimpleSection.o 可以查看到 ELF文件的各个段。

SimpleSection.o:     file format elf64-x86-64
Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         0000004c  0000000000000000  0000000000000000  00000040  2**2
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
  1 .data         00000008  0000000000000000  0000000000000000  0000008c  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  2 .bss          00000004  0000000000000000  0000000000000000  00000094  2**2
                  ALLOC
  3 .rodata       00000004  0000000000000000  0000000000000000  00000094  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  4 .eh_frame     00000058  0000000000000000  0000000000000000  00000098  2**3
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
  5 .note.GNU-stack 00000000  0000000000000000  0000000000000000  000000f0  2**0
                  CONTENTS, READONLY
  6 .comment      0000002d  0000000000000000  0000000000000000  000000f0  2**0
                  CONTENTS, READONLY
  • .text :代码段,包含了 func1()和main()的指令。
  • .data:数据段,保存已经初始化了的全局静态变量和局部静态变量。
  • .rodata:只读数据段,保存只读数据,一般是程序里面的只读变量和字符串常量。
  • .bss:存放未初始化的全局变量和局部静态变量。该段在ELF文件中并没有实际分配空间,只是为这些变量预留位置。

三、静态链接

本节以下面两个源代码 “a.c” 和 “b.c” 展开分析。

// a.c
extern int shared;
int main() {
    int a = 100;
    swap(&a, &shared);
}
// b.c
int shared = 1;
void swap(int *a, int *b) {
    *a ^= *b ^= *a ^= *b;
}

空间与地址分配

这里的空间和地址特指虚拟地址空间的分配。

下面分别查看 a.o / b.o / ab 各个段的属性:

// objdump -h a.o

Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         00000024  0000000000000000  0000000000000000  00000040  2**2
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
  1 .data         00000000  0000000000000000  0000000000000000  00000064  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  2 .bss          00000000  0000000000000000  0000000000000000  00000064  2**2
                  ALLOC
  3 .eh_frame     00000038  0000000000000000  0000000000000000  00000068  2**3
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
  4 .note.GNU-stack 00000000  0000000000000000  0000000000000000  000000a0  2**0
                  CONTENTS, READONLY
  5 .comment      0000002d  0000000000000000  0000000000000000  000000a0  2**0
                  CONTENTS, READONLY
// objdump -h b.o
Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         0000003e  0000000000000000  0000000000000000  00000040  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .data         00000004  0000000000000000  0000000000000000  00000080  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  2 .bss          00000000  0000000000000000  0000000000000000  00000084  2**2
                  ALLOC
  3 .eh_frame     00000038  0000000000000000  0000000000000000  00000088  2**3
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
  4 .note.GNU-stack 00000000  0000000000000000  0000000000000000  000000c0  2**0
                  CONTENTS, READONLY
  5 .comment      0000002d  0000000000000000  0000000000000000  000000c0  2**0
                  CONTENTS, READONLY
// objdump -h ab

Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         00000062  00000000004000e8  00000000004000e8  000000e8  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .eh_frame     00000058  0000000000400150  0000000000400150  00000150  2**3
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  2 .data         00000004  00000000005001a8  00000000005001a8  000001a8  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  3 .bss          00000004  00000000005001ac  00000000005001ac  000001ac  2**2
                  ALLOC
  4 .comment      0000005a  0000000000000000  0000000000000000  000001ac  2**0
                  CONTENTS, READONLY

其中,VMA指的是虚拟地址,LMA指的是加载地址,这两个一般是一样的。

可以看出,在链接之前,目标文件没有分配虚拟地址,链接之后,可执行文件有了虚拟地址。而 .text 段分配的起始地址为 0x4000e8,在Linux下,ELF可执行文件有一个默认起始地址,在我的电脑里是 ox400000。

再将相似段合并并分配虚拟地址后,各个符号的地址也能得到确定,只需要将段的虚拟地址加上符号在段的偏移即可。

重定位

在完成了空间地址分配后,链接器的任务就是对符号进行解析以及重定位。

使用 objdump -d 来查看 “a.o” 和 “ab” 的反汇编结果:

// a.o
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:   c7 45 fc 64 00 00 00    movl   $0x64,0xfffffffffffffffc(%rbp)
   f:   48 8d 7d fc             lea    0xfffffffffffffffc(%rbp),%rdi
  13:   be 00 00 00 00          mov    $0x0,%esi
  18:   b8 00 00 00 00          mov    $0x0,%eax
  1d:   e8 00 00 00 00          callq  22 <main+0x22>
  22:   c9                      leaveq
  23:   c3                      retq
// ab
Disassembly of section .text:
00000000004000e8 <main>:
  4000e8:   55                      push   %rbp
  4000e9:   48 89 e5                mov    %rsp,%rbp
  4000ec:   48 83 ec 10             sub    $0x10,%rsp
  4000f0:   c7 45 fc 64 00 00 00    movl   $0x64,0xfffffffffffffffc(%rbp)
  4000f7:   48 8d 7d fc             lea    0xfffffffffffffffc(%rbp),%rdi
  4000fb:   be a8 01 50 00          mov    $0x5001a8,%esi
  400100:   b8 00 00 00 00          mov    $0x0,%eax
  400105:   e8 02 00 00 00          callq  40010c <swap>
  40010a:   c9                      leaveq
  40010b:   c3                      retq
000000000040010c <swap>:
  40010c:   55                      push   %rbp
  40010d:   48 89 e5                mov    %rsp,%rbp

可以看到,在 a.o 中,左边的还表示的是偏移量,到了ab,就是具体的虚拟地址了。

其中,a.o 文件中,“shared”和“swap”是定义在ab当中,我们可以看到对应于 “shared” 在 a.o 和 ab 之间的改变:

// shared - a.o
13:    be 00 00 00 00          mov    $0x0,%esi
// shared - ab
4000fb:    be a8 01 50 00          mov    $0x5001a8,%esi

“swap”的改变如下所示:

// swap - a.o
1d:    e8 00 00 00 00          callq  22 <main+0x22>
// swap - ab
400105:    e8 02 00 00 00          callq  40010c <swap>

那么,链接器是如何知道哪些指令是需要调整的呢?这就靠之前提到的重定位表了。

使用 objdump -r a.o 查看 a.o 的重定位表:

RELOCATION RECORDS FOR [.text]:
OFFSET           TYPE              VALUE
0000000000000014 R_X86_64_32       shared
000000000000001e R_X86_64_PC32     swap+0xfffffffffffffffc

四、装载

页表和缺页异常大家都懂,但是在操作系统课程中最困扰的一点在于虚拟地址空间在操作系统层面上到底是什么?可执行文件是如何映射的?

操作系统角度

对于操作系统而言,装载可执行文件只需要三个步骤:

  1. 创建一个独立的虚拟地址空间:虚拟空间的核心在于将虚拟空间的各个页映射到物理内存空间,所有这一步,操作系统只需要创建这种映射所需要的数据结构,也就是页目录(页目录,页表这里就不展开说了)。
  2. 读取可执行文件头,建立虚拟空间与可执行文件的映射关系:虚拟存储是以页为单位,以页的大小为对齐大小。这种映射关系也是操作系统的一个数据结构。操作系统把虚拟空间中的一个段称为 VMA。操作系统创建进程后,在进行的数据结构中设置好各个段的VMA。
  3. 将CPU指令寄存器设置为可执行文件入口地址。

虚拟内存空间分布

我们先查看 “ab” 这个可执行文件的段表:

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         00000000004000e8  000000e8    0000000000000062  0000000000000000  AX       0     0     4
  [ 2] .eh_frame         PROGBITS         0000000000400150  00000150    0000000000000058  0000000000000000   A       0     0     8
  [ 3] .data             PROGBITS         00000000005001a8  000001a8    0000000000000004  0000000000000000  WA       0     0     4
  [ 4] .bss              NOBITS           00000000005001ac  000001ac    0000000000000004  0000000000000000  WA       0     0     4
  [ 5] .comment          PROGBITS         0000000000000000  000001ac    000000000000005a  0000000000000000           0     0     1
  [ 6] .shstrtab         STRTAB           0000000000000000  00000206    000000000000003f  0000000000000000           0     0     1
  [ 7] .symtab           SYMTAB           0000000000000000  00000488    0000000000000198  0000000000000018           8    11     8
  [ 8] .strtab           STRTAB           0000000000000000  00000620    0000000000000032  0000000000000000           0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings)
  I (info), L (link order), G (group), x (unknown)
  O (extra OS processing required) o (OS specific), p (processor specific)

可以看到,ab有8个段,如果给每个段按页作为单位进行分配,那么会很大程序的浪费内存空间。

实际上,对于操作系统而言,操作系统不关系段的实际内容,只关心段的权限(可读、可写、可执行)。所有,ELF实际上是通过“程序头(Program Header)”来定义“Segment”,通过将权限相同的段合并为一起,作为一个“Segment”,进行装载。

使用 readelf -l ab 来查看程序头:


Entry point 0x4000e8
There are 3 program headers, starting at offset 64
Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  LOAD           0x0000000000000000 0x0000000000400000 0x0000000000400000
                 0x00000000000001a8 0x00000000000001a8  R E    100000
  LOAD           0x00000000000001a8 0x00000000005001a8 0x00000000005001a8
                 0x0000000000000004 0x0000000000000008  RW     100000
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     8
 Section to Segment mapping:
  Segment Sections...
   00     .text .eh_frame
   01     .data .bss
   02

其中,只有 LOAD 类型的 Segment 需要映射。

实际上,进程地址空间里的堆和栈也是以VMA的形式存在的。

// cat /proc/41731/maps

00400000-0046f000 r-xp 00000000 fd:10 1099862                            /home/jinke02/repos/gcc_learn/SectionMapping.elf
0056e000-00570000 rw-p 0006e000 fd:10 1099862                            /home/jinke02/repos/gcc_learn/SectionMapping.elf
00570000-00593000 rw-p 00000000 00:00 0                                  [heap]
7fff52c1b000-7fff52c30000 rw-p 00000000 00:00 0                          [stack]
7fff52c9d000-7fff52c9e000 r-xp 00000000 00:00 0                          [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0                  [vsyscall]