Skip to content

编译

《程序员的自我修养--链接、装载与库》 - 俞甲子,石凡,潘爱民,第二、三章的读书笔记;以及《深入理解计算机系统》 - Randal E. Bryant - 第三版,第7.6.1章节的读书笔记。本文中的所有代码可在GitHub仓库中找到

生成一个可执行文件

C语言生成一个可执行文件需要4个步骤:

  • 预处理(Preprocessing)
  • 编译(Compilation)
  • 汇编(Assembly)
  • 链接(Linking)

下面以HelloWorld为例,分别介绍各步骤做了什么事情。

预编译

预编译过程通过gcc -E main.c -o main.i命令,处理源代码中以#开头的预编译指令。主要规则如下:

  • 展开宏和头文件
  • 删除注释
  • 添加行号和文件名标识,以便编译器产生调试用的行号信息,例如# 2 "main.c" 2,表示main.c的第二行
    • 文件名后面的数字代表不同的意思,参考GCC手册
      • 1,表示新文件的开始
      • 2,表示返回此文件
      • 3,表示接下来的内容来自系统头文件,某些警告将被忽略
      • 4,表示接下来的内容按C代码处理,即被extern "C"修饰

编译

编译过程就是把预处理完的文件进行一系列词法分析、语法分析、语义分析以及优化后产生相应的汇编代码文件,其命令是:gcc -S main.i -o main.s

汇编

汇编器将汇编代码转变成机器可执行的指令,称为目标文件(Object File)。我们可以调用汇编器as完成,也可以通过GCC命令完成。

  • as main.s -o main.o
  • gcc -c main.s -o main.o

链接

GCC命令gcc main.o -o main,通过链接器,将多个目标文件链接成一个可执行文件。此命令底层是依赖链接器ld完成的,其完整命令如下:

ld -static /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/9/crtbeginT.o \
   -L/usr/lib/gcc/x86_64-linux-gnu/9 -L/usr/lib -L/lib main.o --start-group -lgcc -lgcc_eh -lc --end-group /usr/lib/gcc/x86_64-linux-gnu/9/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o \
   -o main

目标文件

目标文件就是源代码编译后但未进行链接的那些中间文件,它跟可执行文件的内容与结构是很相似的。只是还没有经过链接的过程,其中可能有些符号或有些地址还没有调整。

Linux的ELF(Executable Linkable Format)是一种可执行文件的格式。除了目标文件(.o),动态链接库(.so)、静态链接库(.a)和可执行文件(.out)都是按照这种格式存储的。

ELF文件类型 说明 实例
可重定位文件 (Relocatable File) 这类文件包含了代码和数据,可以被用来链接成可执行文件或共享目标文件,静态链接库也可以归为这一类 .o, .a
可执行文件 (Executable File) 这类文件包含了可以直接执行的程序,它的代表就是ELF可执行文件 /bin/bash
目标共享文件 (Shared Object File) 这种文件包含了代码和数据,通过链接器可以和其他可重定位文件产生新的目标文件。或者和可执行文件结合,作为进程映像的一部分来运行 .so
核心转储文件 (Core Dump File) 当进程意外终止时,系统可以将该进程的地址空间的内容及终止时的一些其他信息转储到核心转储文件 core dump

目标文件中的段

目标文件以段(Segment/Section)的方式,将不同属性的信息组织起来。例如,源代码编译后的机器指令经常放在.text代码段(Code Section)中,而全局变量和局部静态变量经常放在数据段.data(Data Section)。

以下面的代码为例,让我们来看看目标文件的内容。

int global_init_var = 84;   // .data section
int global_uninit_var;      // .bss section

void func1(int i)           // .text section
{
   printf("%d\n", i);
}

int main(void)              // .text section
{
   static int static_var = 85;      // .data section
   static int static_var2;          // .bss section

   int a = 1;
   int b;
   func1(static_var + static_var2 + a + b);

   return 0; 
}
  • .text
    • 代码段,存储执行代码,如:main函数,func1函数
  • .data
    • 数据段,存储以初始化的全局/局部变量,如:global_init_varstatic_var
  • .bss段(Block Started by Symbol)
    • 未初始化的全局/局部变量默认值都是0,为了节省空间,并没有放入数据段,而是在.bss段中预留了位置,.bss段不占据任何空间
  • .rodata
    • 只读数据段,存储只读变量,例如:%d\n字符串

通过objdump工具可以查看目标文件的内容,objdump -h main.o打印"main.o"的各段的首部信息如下:

$ objdump -h main.o

main.o:     file format elf64-x86-64

Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         00000064  0000000000000000  0000000000000000  00000040  2**0
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
  1 .data         00000008  0000000000000000  0000000000000000  000000a4  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  2 .bss          00000008  0000000000000000  0000000000000000  000000ac  2**2
                  ALLOC
  3 .rodata       00000004  0000000000000000  0000000000000000  000000ac  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  4 .comment      0000002c  0000000000000000  0000000000000000  000000b0  2**0
                  CONTENTS, READONLY
  5 .note.GNU-stack 00000000  0000000000000000  0000000000000000  000000dc  2**0
                  CONTENTS, READONLY
  6 .note.gnu.property 00000020  0000000000000000  0000000000000000  000000e0  2**3
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  7 .eh_frame     00000058  0000000000000000  0000000000000000  00000100  2**3
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA

obj_section

根据objdump结果,可得到目标文件"main.o"的结构(如上图)。每个段的第2行表示段属性,如"CONTENTS"表示该段是否在文件中存在。

代码段

通过objdump -s -d main.o可得到各段的内容,并打印反汇编内容。例如,下面就是大小为0x64的.text段的二进制编码。通过反汇编可知,这些二进制编码就是func1main函数的指令。

Contents of section .text:
 0000 f30f1efa 554889e5 4883ec10 897dfc8b  ....UH..H....}..
 0010 45fc89c6 488d0500 00000048 89c7b800  E...H......H....
 0020 000000e8 00000000 90c9c3f3 0f1efa55  ...............U
 0030 4889e548 83ec10c7 45f80100 00008b15  H..H....E.......
 0040 00000000 8b050000 000001c2 8b45f801  .............E..
 0050 c28b45fc 01d089c7 e8000000 00b80000  ..E.............
 0060 0000c9c3

数据段和只读数据段

Contents of section .data:
 0000 54000000 55000000                    T...U...        
Contents of section .rodata:
 0000 25640a00 

.data段保存已经初始化的全局/局部静态变量,如例子中的:global_init_varstatic_var。它们的值分别是84和85,对应于上面的54000000和55000000。在小端模式下,低字节保存在低地址中,因此0x54,0x00,0x00,0x00的存放顺序代表的是整型值84。

.rodata段保存只读数据,如例子中的字符串:%d\n,三个字符的ASCII码分别是:0x25,0x64,0x0A,对应于上面的25640a00。

其他段

常用段名 说明
.comment 存放的是编译器版本信息,比如字符串:"GCC: (Ubuntu 11.1.0-1ubuntu1~20.04) 11.1.0"
.debug 调试信息,在编译的时候加上-g,可以出现相关段
.dynamic 动态链接信息
.hash 符号哈希表
.line 调试时的行号表
.note 额外的编译信息,比如程序的公司名、发布版本号等
.strtab String Table字符串表,用于存储ELF文件中用到的各种字符串
.symtab Symbol Table符号表,包括:全局函数和变量(本模块定义/引用的),局部静态函数和变量
.shstrtab Section String Table段名表
.plt .got 动态链接的跳转表和全局入口表
.init .fini 程序初始化与终结代码段,出现在C++代码中

自定义段

在全局变量或函数之前加上__attribute__((section("name")))属性就可以把相应的变量或函数放到以"name"作为段名的段中。如:

// 将`global`全局变量放入"FOO"段名中
__attribute__((section("FOO"))) int global = 42;
// 将`foo`函数放入"BAR"段名中
__attribute__((section("BAR"))) void foo()
{
}

ELF文件结构

前面我们通过objdump工具,大致了解了目标文件中常见的段。接下来我们来详细地看看ELF目标文件的结构。ELF目标文件主要包括:

  • ELF文件头(ELF Header)
    • 在文件最前部,描述整个文件的基本属性,比如ELF文件版本、目标机器型号、程序入口地址等
  • 各个段的内容
    • ELF文件的主体内容
  • 段表(Section Header)
    • 描述了ELF文件包含的所有段的信息,比如每个段的段名、段的长度、在文件中的偏移、读写权限及段的其他属性

readelf -h main.o命令会读取"main.o"的ELF文件头,并打印如下信息:

ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              REL (Relocatable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x0
  Start of program headers:          0 (bytes into file)
  Start of section headers:          1176 (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:         14
  Section header string table index: 13

readelf -S main.o命令会读取"main.o"的段表,并打印如下信息:

There are 14 section headers, starting at offset 0x498:

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
       0000000000000064  0000000000000000  AX       0     0     1
  [ 2] .rela.text        RELA             0000000000000000  00000378
       0000000000000078  0000000000000018   I      11     1     8
  [ 3] .data             PROGBITS         0000000000000000  000000a4
       0000000000000008  0000000000000000  WA       0     0     4
  [ 4] .bss              NOBITS           0000000000000000  000000ac
       0000000000000008  0000000000000000  WA       0     0     4
  [ 5] .rodata           PROGBITS         0000000000000000  000000ac
       0000000000000004  0000000000000000   A       0     0     1
  [ 6] .comment          PROGBITS         0000000000000000  000000b0
       000000000000002c  0000000000000001  MS       0     0     1
  [ 7] .note.GNU-stack   PROGBITS         0000000000000000  000000dc
       0000000000000000  0000000000000000           0     0     1
  [ 8] .note.gnu.propert NOTE             0000000000000000  000000e0
       0000000000000020  0000000000000000   A       0     0     8
  [ 9] .eh_frame         PROGBITS         0000000000000000  00000100
       0000000000000058  0000000000000000   A       0     0     8
  [10] .rela.eh_frame    RELA             0000000000000000  000003f0
       0000000000000030  0000000000000018   I      11     9     8
  [11] .symtab           SYMTAB           0000000000000000  00000158
       00000000000001b0  0000000000000018          12    12     8
  [12] .strtab           STRTAB           0000000000000000  00000308
       000000000000006d  0000000000000000           0     0     1
  [13] .shstrtab         STRTAB           0000000000000000  00000420
       0000000000000074  0000000000000000           0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  l (large), p (processor specific)

结合上面打印的信息,我们可以画出"main.o"文件的结构如下:

obj_section_detail

链接的接口--符号

每个目标文件都会有一个相应的符号表(Symbol Table),记录了目标文件中所用到的所有符号。符号除了是常见的函数和变量外,还有可能是其他类型。通过nm工具可以查看目标文件中的符号。

$ nm main.o
0000000000000000 T func1                    # T, in text section
0000000000000000 D global_init_var          # D, in data section
                 U _GLOBAL_OFFSET_TABLE_    # U, undefined
0000000000000000 B global_uninit_var        # B, in BSS data section
000000000000002b T main
                 U printf
0000000000000004 d static_var.1             # d, in data section
0000000000000004 b static_var2.0            # b, in BSS data section
  • 常见的符号类型
    • 内部全局符号 - 定义在本目标文件的全局符号,可以被其他目标文件引用
    • 外部全局符号 - 在本目标文件中引用的全局符号,却没有定义在本目标文件
    • 段名 - 由编译器产生,它的值就是该段的起始地址
    • 局部符号 - 只在编译单元内部可见
    • 行号信息 - 目标文件指令与源代码行的对应关系

特殊符号

当用ld作为链接器产生可执行文件时,它会为我们定义很多特殊的符号,例如:

  • __executable_start - 程序的起始地址
  • etext, _etext, __etext - 代码段结束地址
  • edata, _edata - 数据段结束地址
  • end, _end - 程序结束地址

通过GDB调试相关代码,可得到如下信息。其中,起始地址0x555555554000和结束地址0x555555558018,分别映射在了相应的虚拟地址空间中。

Mapped address spaces:

        Start Addr           End Addr       Size     Offset objfile
    0x555555554000     0x555555555000     0x1000        0x0 /home/yuxiangw/GitHub/learning_book/docs/booknotes/cxydzwxy/compile/code/ld_sym/main
    0x555555555000     0x555555556000     0x1000     0x1000 /home/yuxiangw/GitHub/learning_book/docs/booknotes/cxydzwxy/compile/code/ld_sym/main
    0x555555556000     0x555555557000     0x1000     0x2000 /home/yuxiangw/GitHub/learning_book/docs/booknotes/cxydzwxy/compile/code/ld_sym/main
    0x555555557000     0x555555558000     0x1000     0x2000 /home/yuxiangw/GitHub/learning_book/docs/booknotes/cxydzwxy/compile/code/ld_sym/main
    0x555555558000     0x555555559000     0x1000     0x3000 /home/yuxiangw/GitHub/learning_book/docs/booknotes/cxydzwxy/compile/code/ld_sym/main

Executable Start 0x555555554000
Text End 0x555555555285 0x555555555285 0x555555555285
Data End 0x555555558010 0x555555558010
Executable End 0x555555558018 0x555555558018

C++符号修饰

为了支持C++函数重载,命名空间等复杂的特性,人们发明了符号修饰(Name Decoration)和符号改编(Name Mangling)机制。

函数签名 修饰后名称(符号名)
int func(int) _Z4funci
float func(float) _Z4funcf
int C::func(int) _ZN1C4funcEi
int C::C2::func(int) _ZN1C2C24funcEi
int N::func(int) _ZN1N4funcEi
int N::C::func(int) _ZN1N1C4funcEi

上表显示了代码中,不同函数的符号名。以_Z开头,后面紧跟N,然后是各名称空间和类的名字,每个名字前是名字字符串长度,再以E结尾。参数列表紧跟在E后面。c++filt工具可以用来解析被修饰过的名称。

extern "C"

C++用extern "C"关键字来声明或定义一个C的符号。修饰后,C++的名称修饰机制将不会起作用。常见的写法如下:

#ifdef __cplusplus
extern "C" {
#endif

void *memset (void *, int, size_t);

#ifdef __cplusplus
}
#endif

弱符号与强符号

我们经常会遇到符号重定义的错误。例如,目标文件A和目标文件B都定义了一个全局变量global,则链接时会报错:

b.o: multiple definition of `global`
a.o: first defined here

符号的定义分为强符号(Strong Symbol)定义和弱符号(Weak Symbol)定义。通过GCC修饰符__attribute__((weak))可以定义弱符号,包括弱全局变量和弱函数。

例如,"weak.c"定义了弱变量bar和弱函数foo

int __attribute__((weak)) bar = 3;

void __attribute__((weak)) foo(int a, int b)
{
   printf("weak version foo(%d, %d) with bar %d, sizeof(bar) = %zu\n", a, b, bar, sizeof(bar));
}
"strong.c"定义了强变量bar和强函数foo
#include <stdio.h>

long bar = 100;

void foo(int a, int b)
{
   printf("strong version foo(%d, %d) with bar %ld, sizeof(bar) = %zu\n", a, b, bar, sizeof(bar));
}

Linux链接器使用如下规则处理多重定义的符号:

  • 规则1:不允许有多个同名的强符号
  • 规则2:如果有一个强符号和多个弱符号同名,那么选择强符号
  • 规则3:如果有多个弱符号同名,那么从这些弱符号中任意选择一个
# strong.o中强类型的`bar`变量和`foo`函数
> nm strong.o | egrep 'bar|foo'
0000000000000000 D bar   # The symbol is in the initialized data section.
0000000000000000 T foo   # The symbol is in the text (code) section.
> readelf -s strong.o | egrep 'bar|foo'
    73: 0000000000000000     8 OBJECT  GLOBAL DEFAULT   22 bar
    74: 0000000000000000    62 FUNC    GLOBAL DEFAULT   20 foo

# weak.o中弱类型的`bar`变量和`foo`函数
> nm weak.o | egrep 'bar|foo'
0000000000000000 V bar   # The symbol is a weak object.
0000000000000000 W foo   # The symbol is a weak symbol
> readelf -s weak.o | egrep 'bar|foo'
    73: 0000000000000000     4 OBJECT  WEAK   DEFAULT   22 bar
    74: 0000000000000000    61 FUNC    WEAK   DEFAULT   20 foo

# program1只包含了weak.o
> nm program1 | egrep 'bar|foo'
0000000000004010 V bar
0000000000001167 W foo
> readelf -s program1 | egrep 'bar|foo'
    57: 0000000000004010     4 OBJECT  WEAK   DEFAULT   25 bar
    66: 0000000000001167    61 FUNC    WEAK   DEFAULT   16 foo

# program1同时包含了weak.o和strong.o
> nm program2 | egrep 'bar|foo'
0000000000004018 D bar
00000000000011a4 T foo
> readelf -s program2 | egrep 'bar|foo'
    58: 0000000000004018     8 OBJECT  GLOBAL DEFAULT   25 bar
    67: 00000000000011a4    62 FUNC    GLOBAL DEFAULT   16 foo

COMMON符号

当编译器在编译某个模块时,遇到一个弱全局符号,比如说x,它并不知道其他模块是否也定义了x。如果是,它无法预测链接器该使用x多重定义中的哪一个。所以编译器把x分配成COMMON,把决定权留给链接器。另一方面,如果x被明确初始化,那么它是一个强符号,所以编译器可用很自信地将它分配成.data(初始化为非零)或.bss(初始化为零)。类似地,对于静态符号,编译器可以自信地把它们分配成.data.bss

例子"common"编译了两个模块main.of.o,其中:

  • f.o定义了弱符号x,存于COMMON
  • main.o定义了强符号xyzv
    • xyz被初始化为非零,因此存于.data
    • v被初始化为零,因此存于.bss
> readelf -s f.o
  ...
  73: 0000000000000008     8 OBJECT  GLOBAL DEFAULT  COM x

> readelf -s main.o
  ...
  73: 0000000000000000     4 OBJECT  GLOBAL DEFAULT   22 x
  74: 0000000000000004     4 OBJECT  GLOBAL DEFAULT   22 y
  75: 0000000000000008     4 OBJECT  GLOBAL DEFAULT   22 z
  76: 0000000000000000     4 OBJECT  GLOBAL DEFAULT   23 v

> readelf -S main.o
  ...
  [22] .data             PROGBITS         0000000000000000  000001d4
       000000000000000c  0000000000000000  WA       0     0     4
  [23] .bss              NOBITS           0000000000000000  000001e0
       0000000000000004  0000000000000000  WA       0     0     4

f.c定义的xdouble类型,而main.c定义的xint类型,这在链接时会触发警告。并且在运行时,f函数对x的写操作,会影响y的值:

> gcc  main.o f.o -o main
/usr/bin/ld: warning: alignment 4 of symbol `x' in main.o is smaller than 8 in f.o

> ./main
[main] &x=0x0x55892d153010, sizeof(x)=4
[main] &y=0x0x55892d153014, sizeof(y)=4
[main] &z=0x0x55892d153018, sizeof(z)=4
[main] &v=0x0x55892d153020, sizeof(v)=4
[main] before f(), x = 0x1, y = 0x2, z = 0x3
[f] &x=0x0x55892d153010, sizeof(x)=8
[f] x = -0.000000
[main] after f(), x = 0x0, y = 0x80000000, z = 0x3

调试信息

如果我们在GCC编译时加上-g参数,在产生的目标文件里就会加上调试信息,会多出很多"debug"相关的段。例如,"main.c"的调式信息段如下:

  [28] .debug_aranges    PROGBITS         0000000000000000  0000303b
       0000000000000030  0000000000000000           0     0     1
  [29] .debug_info       PROGBITS         0000000000000000  0000306b
       00000000000000bc  0000000000000000           0     0     1
  [30] .debug_abbrev     PROGBITS         0000000000000000  00003127
       0000000000000088  0000000000000000           0     0     1
  [31] .debug_line       PROGBITS         0000000000000000  000031af
       000000000000005f  0000000000000000           0     0     1
  [32] .debug_str        PROGBITS         0000000000000000  0000320e
       0000000000000136  0000000000000001  MS       0     0     1

strip工具可以删除上述调试信息,同时strip还会删除.symtab段和.strtab段,以节省空间。因此,strip后的目标文件将无法使用readelf -s查看符号表,同时也无法使用GDB打断点。但是,只是没有调试信息的目标文件是包含符号表的,并可以通过GDB打断点。